diff --git a/.gitea/actions/setup-godot/action.yaml b/.gitea/actions/setup-godot/action.yaml new file mode 100644 index 0000000..dc49d1d --- /dev/null +++ b/.gitea/actions/setup-godot/action.yaml @@ -0,0 +1,38 @@ +name: "Setup Godot" +description: "Downloads Godot for Linux, puts it on PATH as `godot` and optionally installs the export templates" + +inputs: + version: + description: "Godot version, e.g. 4.7.2" + required: true + export-templates: + description: "Also install the export templates ('true' or 'false')" + required: false + default: "false" + +runs: + using: "composite" + steps: + - name: Download Godot + shell: bash + run: | + URL="https://github.com/godotengine/godot-builds/releases/download/${{ inputs.version }}-stable" + mkdir -p "$HOME/.godot-bin" + curl --fail -L -o godot.zip "$URL/Godot_v${{ inputs.version }}-stable_linux.x86_64.zip" + unzip -q godot.zip + mv "Godot_v${{ inputs.version }}-stable_linux.x86_64" "$HOME/.godot-bin/godot" + chmod +x "$HOME/.godot-bin/godot" + rm godot.zip + echo "$HOME/.godot-bin" >> "$GITHUB_PATH" + + - name: Install Export Templates + if: inputs.export-templates == 'true' + shell: bash + run: | + URL="https://github.com/godotengine/godot-builds/releases/download/${{ inputs.version }}-stable" + TEMPLATES_DIR="$HOME/.local/share/godot/export_templates/${{ inputs.version }}.stable" + curl --fail -L -o export_templates.tpz "$URL/Godot_v${{ inputs.version }}-stable_export_templates.tpz" + unzip -q export_templates.tpz + mkdir -p "$TEMPLATES_DIR" + mv templates/* "$TEMPLATES_DIR" + rm -r templates export_templates.tpz diff --git a/.gitea/workflows/gitea-build.yaml b/.gitea/workflows/gitea-build.yaml index 3c512e0..dda2fd5 100644 --- a/.gitea/workflows/gitea-build.yaml +++ b/.gitea/workflows/gitea-build.yaml @@ -1,59 +1,113 @@ # # .gitea/workflows/gitea-build.yaml # +# Tests and exports MusicPlayer on every push and pull request to main and develop, +# and releases it from main. +# +# Releases follow the version in project.godot (application/config/version). Bump it, +# push to main, and once the tests and exports pass the builds are uploaded to the +# package registry and the tag and Gitea release are created. If that version already +# has a tag, nothing is released. See RELEASING.md. +# +# All jobs run in this one workflow, one after another: separate workflows started by +# the same push run at the same time on the single runner and can break each other's +# copy of a shared action. # name: "Build" -on: - push: +on: + push: + branches: [main, develop] + pull_request: branches: [main, develop] env: - GODOT_VERSION: 4.7 - PROJECT_PATH: . - EXPORT_NAME: MusicPlayer + GODOT_VERSION: 4.7.2 + PROJECT_NAME: MusicPlayer jobs: - build: + test: runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v4 - - name: Download Godot + + - name: Setup Godot + uses: ./.gitea/actions/setup-godot + with: + version: ${{ env.GODOT_VERSION }} + + - name: Run Tests + run: ./run_tests.sh + + build: + needs: test + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Read Version + id: version run: | - #echo "https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_linux.x86_64.zip" - curl -L -o godot.zip https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_linux.x86_64.zip - unzip godot.zip - mv Godot_v$GODOT_VERSION-stable_linux.x86_64 godot - chmod +x godot + GAME_VERSION="$(sed -n 's/^config\/version="\(.*\)"$/\1/p' project.godot)" + if [ -z "$GAME_VERSION" ]; then + echo "No application/config/version in project.godot" + exit 1 + fi + echo "GAME_VERSION=$GAME_VERSION" >> "$GITHUB_ENV" + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "Not on main, building without releasing" + echo "release=false" >> "$GITHUB_OUTPUT" + elif git rev-parse -q --verify "refs/tags/$GAME_VERSION" > /dev/null; then + echo "$GAME_VERSION is already released, building without releasing" + echo "release=false" >> "$GITHUB_OUTPUT" + else + echo "Releasing $GAME_VERSION" + echo "release=true" >> "$GITHUB_OUTPUT" + fi - - name: Download Godot Export Templates + - name: Setup Godot + uses: ./.gitea/actions/setup-godot + with: + version: ${{ env.GODOT_VERSION }} + export-templates: "true" + + - name: Export run: | - #echo "https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_export_templates.tpz" - curl -L -o export_templates.zip https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_export_templates.tpz - unzip export_templates.zip + mkdir -p build + godot --headless --path . --import + godot --headless --path . --export-release "Windows Desktop" "build/${PROJECT_NAME}_${GAME_VERSION}.exe" + godot --headless --path . --export-release "Linux" "build/${PROJECT_NAME}_${GAME_VERSION}.x86_64" - - name: Install Export Templates + # Linux binaries can't hold an icon; the package adds it with a menu entry and installer + - name: Package Linux run: | - mkdir -p ~/.local/share/godot/export_templates/$GODOT_VERSION.stable - mv ./templates/* ~/.local/share/godot/export_templates/$GODOT_VERSION.stable + linux/package.sh "build/${PROJECT_NAME}_${GAME_VERSION}.x86_64" "$GAME_VERSION" build + ls -la build - - name: Create build folder + - name: Upload Artifacts + if: steps.version.outputs.release == 'true' run: | - mkdir -v -p build - - - name: Windows Build - run: | - cd $PROJECT_PATH - ./godot --headless --verbose --export-release "Windows Desktop" "build/$EXPORT_NAME.exe" - - - name: Linux Build - run: | - cd $PROJECT_PATH - ./godot --headless --verbose --export-release "Linux" "build/$EXPORT_NAME.x86_64" - -# - name: MacOS Build -# run: | -# cd $PROJECT_PATH -# ./godot --headless --verbose --export-release "macOS" "build/$EXPORT_NAME.zip" + for FILE in build/${PROJECT_NAME}_${GAME_VERSION}*; do + NAME="$(basename "$FILE")" + echo "Uploading $NAME" + curl --fail --user ${{ github.repository_owner }}:${{ secrets.TOKEN }} \ + --upload-file "$FILE" \ + https://gitea.sanplex.xyz/api/packages/sansan/generic/$PROJECT_NAME/$GAME_VERSION/$NAME + done + - name: Create Release + if: steps.version.outputs.release == 'true' + uses: akkuman/gitea-release-action@v1 + env: + NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18 + with: + tag_name: ${{ env.GAME_VERSION }} + target_commitish: ${{ github.sha }} + files: |- + build/${{ env.PROJECT_NAME }}_* diff --git a/.gitea/workflows/gitea-release.yaml b/.gitea/workflows/gitea-release.yaml deleted file mode 100644 index 084ea63..0000000 --- a/.gitea/workflows/gitea-release.yaml +++ /dev/null @@ -1,90 +0,0 @@ -# -# .gitea/workflows/gitea-release.yaml -# -# - -name: "Publish" -on: - push: - branches: - - '*.*' - -env: - GODOT_VERSION: 4.7 - PROJECT_PATH: . - PROJECT_NAME: MusicPlayer - GAME_VERSION: ${{gitea.ref_name}} - EXPORT_NAME: MusicPlayer_${{gitea.ref_name}} - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - name: Download Godot - run: | - #echo "https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_linux.x86_64.zip" - curl -L -o godot.zip https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_linux.x86_64.zip - unzip godot.zip - mv Godot_v$GODOT_VERSION-stable_linux.x86_64 godot - chmod +x godot - - - name: Download Godot Export Templates - run: | - #echo "https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_export_templates.tpz" - curl -L -o export_templates.zip https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_export_templates.tpz - unzip export_templates.zip - - - name: Install Export Templates - run: | - mkdir -p ~/.local/share/godot/export_templates/$GODOT_VERSION.stable - mv ./templates/* ~/.local/share/godot/export_templates/$GODOT_VERSION.stable - - - name: Create build folder - run: | - mkdir -v -p build - - - name: Windows Build - run: | - cd $PROJECT_PATH - ./godot --headless --verbose --export-release "Windows Desktop" "build/$EXPORT_NAME.exe" - - - name: Linux Build - run: | - cd $PROJECT_PATH - ./godot --headless --verbose --export-release "Linux" "build/$EXPORT_NAME.x86_64" - -# - name: MacOS Build -# run: | -# cd $PROJECT_PATH -# ./godot --headless --verbose --export-release "macOS" "build/$EXPORT_NAME.zip" - - - name: Upload Artifact Windows - run: | - #ls -la build - curl --user ${{ github.repository_owner }}:${{ secrets.TOKEN }} \ - --upload-file "build/$EXPORT_NAME.exe" \ - https://gitea.sanplex.xyz/api/packages/sansan/generic/$PROJECT_NAME/$GAME_VERSION/$EXPORT_NAME.exe - - - name: Upload Artifact Linux - run: | - ls -la build - curl --user ${{ github.repository_owner }}:${{ secrets.TOKEN }} \ - --upload-file "build/$EXPORT_NAME.x86_64" \ - https://gitea.sanplex.xyz/api/packages/sansan/generic/$PROJECT_NAME/$GAME_VERSION/$EXPORT_NAME.x86_64 - -# - name: Upload Artifact MacOS -# run: | -# ls -la build -# curl --user ${{ github.repository_owner }}:${{ secrets.TOKEN }} \ -# --upload-file "build/$EXPORT_NAME.zip" \ -# https://gitea.sanplex.xyz/api/packages/sansan/generic/$PROJECT_NAME/$GAME_VERSION/$EXPORT_NAME.zip - - - name: Create release - uses: akkuman/gitea-release-action@v1 - env: - NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18 - with: - files: |- - build/** diff --git a/.gitignore b/.gitignore index 35d73f9..b7379bc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ .idea .vscode .DS_Store + +# GdUnit4 test reports +reports/ diff --git a/CharacterSelect.gd b/CharacterSelect.gd deleted file mode 100644 index cab565e..0000000 --- a/CharacterSelect.gd +++ /dev/null @@ -1,40 +0,0 @@ -extends Control - -@onready -var character_grid_container := $ScrollContainer2 - -@onready -var character_grid := $ScrollContainer2/GridContainer - -signal character_selected(file_name) - -# Called when the node enters the scene tree for the first time. -func _ready(): - var characters := DirAccess.open("res://characters/") - if characters: - characters.list_dir_begin() - var file_name = characters.get_next() - while file_name != "": - file_name = file_name.replace('.import', '') # <--- remove the .import - if file_name.ends_with(".png"): - - var texture = load("res://characters/" + file_name) - - var texture_btn := TextureButton.new() - - character_grid.add_child(texture_btn) - texture_btn.custom_minimum_size = Vector2(80, 40) - texture_btn.ignore_texture_size = true - texture_btn.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT - texture_btn.texture_normal = texture - - texture_btn.pressed.connect(select_character.bind(file_name)) - - file_name = characters.get_next() - -func show_grid(): - character_grid_container.visible = true - -func select_character(file_name: String): - print("select_character") - character_selected.emit(file_name) diff --git a/CharacterSelect.gd.uid b/CharacterSelect.gd.uid deleted file mode 100644 index 438cd7b..0000000 --- a/CharacterSelect.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cmxl2w45vvmx6 diff --git a/CharacterSelect.tscn b/CharacterSelect.tscn deleted file mode 100644 index 306b922..0000000 --- a/CharacterSelect.tscn +++ /dev/null @@ -1,25 +0,0 @@ -[gd_scene load_steps=2 format=3 uid="uid://b16on0oscg1bv"] - -[ext_resource type="Script" uid="uid://cmxl2w45vvmx6" path="res://CharacterSelect.gd" id="1_4bba6"] - -[node name="CharacterSelect" type="Control"] -layout_mode = 3 -anchors_preset = 0 -offset_right = 40.0 -offset_bottom = 40.0 -script = ExtResource("1_4bba6") - -[node name="ScrollContainer2" type="ScrollContainer" parent="."] -clip_contents = false -custom_minimum_size = Vector2(100, 100) -layout_mode = 0 -offset_left = 194.0 -offset_top = 152.0 -offset_right = 670.0 -offset_bottom = 375.0 - -[node name="GridContainer" type="GridContainer" parent="ScrollContainer2"] -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 3 -columns = 3 diff --git a/InsperationScrollContainer.gd b/InsperationScrollContainer.gd deleted file mode 100644 index aa83b8b..0000000 --- a/InsperationScrollContainer.gd +++ /dev/null @@ -1,38 +0,0 @@ -extends ScrollContainer - -# Scroll direction: 1 = down, -1 = up -var scroll_direction: int = 1 - -# Called when the node enters the scene tree for the first time. -func _ready() -> void: - scroll_direction = 1 - -# Called every frame. 'delta' is the elapsed time since the previous frame. -func _process(delta: float) -> void: - var speed_pixels_per_second: float = float(Settings.inspiration_list_speed) * 60.0 - var scroll_amount: float = speed_pixels_per_second * delta * float(scroll_direction) - - var scroll_bar = get_v_scroll_bar() - - # Don't scroll if list is empty or too short - if scroll_bar.max_value <= 0: - return - - var current_pos = scroll_bar.value - - if scroll_direction == 1: - # Scrolling down - if current_pos + scroll_bar.page < scroll_bar.max_value: - scroll_bar.value = min(current_pos + scroll_amount, scroll_bar.max_value - scroll_bar.page) - else: - # Reached bottom, reverse direction - scroll_direction = -1 - scroll_bar.value = scroll_bar.max_value - scroll_bar.page - else: - # Scrolling up - if current_pos > 0: - scroll_bar.value = max(current_pos + scroll_amount, 0) - else: - # Reached top, reverse direction - scroll_direction = 1 - scroll_bar.value = 0 diff --git a/LightGrayTheme.tres b/LightGrayTheme.tres deleted file mode 100644 index c48b64e..0000000 --- a/LightGrayTheme.tres +++ /dev/null @@ -1,7 +0,0 @@ -[gd_resource type="Theme" load_steps=2 format=3 uid="uid://rxexo3ur85as"] - -[ext_resource type="StyleBox" uid="uid://blf3g88mugovr" path="res://neutral_gray.tres" id="1_alhp2"] - -[resource] -Label/styles/normal = ExtResource("1_alhp2") -PanelContainer/styles/panel = ExtResource("1_alhp2") diff --git a/MainWindow.gd b/MainWindow.gd deleted file mode 100644 index e5a53d0..0000000 --- a/MainWindow.gd +++ /dev/null @@ -1,540 +0,0 @@ -extends Control - -# 601 LOC 11/9 - 2023 -# 628 LOC 15/9 - 2023 -# 631 LOC 16/9 - 2023 -# 554 LOC 30/5 - 2024 -# 551 LOC 3/6 - 2024 -# 386 LOC 12/6 - 2024 - -##TODO -# 15. Fix for local play - -@onready -var open_button: Button = $Open - -@onready -var fileDialog: FileDialog = $FileDialog - -@onready -var inspiration_scroll: ScrollContainer = $ScrollContainer - -@onready -var inspiration_list: VBoxContainer = $ScrollContainer/VBoxContainer - -@onready -var soundtrack_label: Label = $VBoxContainer/SoundtrackLabel - -@onready -var song_label: Label = $VBoxContainer/SongLabel - -@onready -var add_player_container: HBoxContainer = $Players/VBoxContainer/AddPlayerContainer - -@onready -var add_players_button: TextureButton = $Players/VBoxContainer/HBoxContainer/AddPlayersButton - -@onready -var add_player_button: Button = $Players/VBoxContainer/AddPlayerContainer/AddPlayerButton - -@onready -var new_player_name_field: TextEdit = $Players/VBoxContainer/AddPlayerContainer/PlayerNameField - -@onready -var reset_playlist_button: Button = $ResetPlaylistButton - -@onready -var reset_points_button: Button = $ResetPointsButton - -@onready -var sound_test_button: Button = $SoundTestButton - -@onready -var sync_button: Button = $SyncButton - -@onready -var sync_window: Control = %SyncWindow - -@onready -var settings_button: Button = $SettingsButton - -@onready -var settings_window: Control = $SettingsWindow - -@onready -var shortcuts_window: Control = %ShortcutsWindow - -@onready -var qr_window: Control = %QRWindow - -@onready -var winner_window: Control = %WinnerWindow - -@onready -var qr_button: Button = $QRButton - -@onready -var statistics_button: Button = $StatisticsButton - -@onready -var statistic_popup: PopupPanel = $StatisticsPopupPanel - -@onready -var statistic_label: Label = $StatisticsPopupPanel/StatisticsLabel - -@onready -var shortcuts_button: Button = $ShortcutsButton - -@onready -var about_button: Button = $AboutButton - -@onready -var about_window: Control = %AboutWindow - -@onready -var show_answer_button: Button = $ShowAnswerButton - -@onready -var next_button: Button = $NextButton - -@onready -var music_list_scroll: ScrollContainer = $MusicListPanel/ScrollContainer - -@onready -var music_list: VBoxContainer = $MusicListPanel/ScrollContainer/MusicList - -@onready -var player_container: VBoxContainer = $Players/VBoxContainer - -@onready -var players_list_container: VBoxContainer = $Players/VBoxContainer/ScrollContainer/PlayersListVBoxContainer - -@onready -var character_select: Control = $CharacterSelect - -@onready -var new_character_select: Control = %NewCharacterSelect - -@onready -var search_button: Button = $SearchButton - -@onready -var search_view: Control= $Search - -@onready -var auto_repeat_song_button: CheckButton = $RepeatSongCheckButton - -@onready -var music_player_container: PanelContainer = $MusicPlayer - -@onready -var log_window: Control = %Log - -@onready -var debug_label: Label = $DebugLabel - -var player: PackedScene = preload("res://Player.tscn") -var song_list_object: PackedScene = preload("res://song_list_object.tscn") - -var songs: Array= [] -var soundtracks: Array = [] -var current_player: Node - -# Called when the node enters the scene tree for the first time. -func _ready() -> void: - var debug: bool = OS.has_feature("debug") - var is_mac: bool = OS.has_feature("macos") - if debug: - print("is_debug") - debug_label.visible = true - Settings.is_debug = true - settings_window.set_winning_score(5) - settings_window.set_selected_server(4) - if is_mac: - print("is_mac") - next_button.pressed.connect(play_next_song) - show_answer_button.pressed.connect(show_answer_pressed) - search_button.pressed.connect(show_search) - sync_button.pressed.connect(sync_soundtracks) - sound_test_button.pressed.connect(music_player_container.get_sound_test_song) - statistics_button.pressed.connect(get_statistics) - shortcuts_button.pressed.connect(show_shortcuts) - about_button.pressed.connect(show_about) - settings_button.pressed.connect(show_settings) - qr_button.pressed.connect(show_qr) - reset_playlist_button.pressed.connect(reset_playlist) - reset_points_button.pressed.connect(reset_points) - - character_select.connect("character_selected", _on_character_selected) - new_character_select.connect("new_new_character_selected", _on_new_character_selected) - new_player_name_field.connect("enter_key_pressed", add_player) - music_player_container.connect("play_next_song", play_next_song) - music_player_container.connect("update_song_list", update_song_list) - settings_window.connect("server_changed", server_updated) - sync_window.connect("sync_started", _on_sync_started) - sync_window.connect("sync_finished", _on_sync_finished) - - - add_players_button.pressed.connect(add_players) - add_player_button.pressed.connect(add_player) - open_button.pressed.connect(open) - auto_repeat_song_button.pressed.connect(repeat_song) - - get_suggestion_list() - fetch_song_list_at_start() - fetch_song() - search_view.get_list_of_soundtracks() - new_character_select.load_characters() - -func _input(event: InputEvent) -> void: - if event is InputEventKey and event.pressed: - if event.alt_pressed && event.keycode == KEY_UP: - print("Alt + UP pressed") - music_player_container.change_volume(0.05) - if event.alt_pressed && event.keycode == KEY_DOWN: - print("Alt + DOWN pressed") - music_player_container.change_volume(-0.05) - if event.alt_pressed && event.ctrl_pressed && event.keycode == KEY_LEFT: - print("Ctrl + Alt + LEFT pressed") - music_player_container.restart() - elif event.alt_pressed && event.keycode == KEY_LEFT: - print("Alt + LEFT pressed") - music_player_container.seek(-5) - if event.alt_pressed && event.keycode == KEY_RIGHT: - print("Alt + RIGHT pressed") - music_player_container.seek(5) - if event.alt_pressed && event.keycode == KEY_ENTER: - print("Alt + Enter pressed") - Settings.fullscreen = !Settings.fullscreen - settings_window.fullscreen_button.button_pressed = Settings.fullscreen - if Settings.fullscreen == true: - DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) - if Settings.fullscreen == false: - DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) - if event.alt_pressed && event.keycode == KEY_K: - shortcuts_window.visible = !shortcuts_window.visible - if event.alt_pressed && event.keycode == KEY_L: - log_window.visible = !log_window.visible - -func server_updated() -> void: - print("server_updated") - update_from_server() - reset_points() - -func update_from_server() -> void: - print("update_from_server") - search_view.get_list_of_soundtracks() - get_suggestion_list() - reset_playlist() - new_character_select.load_characters() - -func repeat_song() -> void: - Settings.auto_repeat_song = !Settings.auto_repeat_song - -func show_answer_pressed() -> void: - print("show_answer_pressed") - Playlist.song_is_answered() - update_song_list() - -func fetch_song_list_at_start() -> void: - print("fetch_song_list_at_start") - var fetch_song_list_at_start_done: Callable = func fetch_songs_at_start_done(data) -> void: - Playlist.clear_song_list() - if data == null: - Playlist.update_last_played_song() - update_song_list() - return - if typeof(data) == TYPE_ARRAY: - for d: Dictionary in data: - var new_song_object: SongObject = SongObject.new() - new_song_object.song_number = d.SongNo - new_song_object.soundtrack_title = d.Soundtrack - new_song_object.song_title = d.Song - new_song_object.is_answered = true - new_song_object.has_played = true - Playlist.add_to_playlist(new_song_object) - else: - print("Unexpected data") - Playlist.update_last_played_song() - update_song_list() - Settings.make_request2("/music/list", fetch_song_list_at_start_done, true) - -func play_next_song() -> void: - print("play_next_song ", Playlist.latest_played_song) - if Playlist.first_song_played: - Playlist.set_latest_played_song() - Playlist.first_song_played = true - music_player_container.play_song_object(Playlist.latest_played_song) - if !(Playlist.get_preloaded_songs() > 0): - next_button.disabled = true - if !Playlist.is_song_downloading: - fetch_song() - -#not first "/music/addPlayed" -#"/music/rand" -#"/music/addQue" -#"/music/info" -func fetch_song() -> void: - print("fetch_song") - Playlist.is_song_downloading = true - var url: String = "" - if Settings.use_low_played_mode: - url = "/music/rand/low" - else: - url = "/music/rand" - var new_song_object: SongObject = SongObject.new() - var fetch_song_done: Callable = func fetch_song_done(body: PackedByteArray) -> void: - print("fetch_song_done") - var sound: AudioStream = AudioStreamMP3.new() - sound.data = body - new_song_object.song = sound - - var added_to_que_done: Callable = func added_to_que_done() -> void: - print("added_to_que_done") - var fetch_song_info_done: Callable = func fetch_song_info_done(data_received: Dictionary) -> void: - print("fetch_song_info_done") - new_song_object.song_number = data_received.SongNo - new_song_object.soundtrack_title = data_received.Soundtrack - new_song_object.song_title = data_received.Song - - print("new_song_object: ", new_song_object.to_string()) - Playlist.add_to_playlist(new_song_object) - next_button.disabled = false - if Playlist.should_preload_more_songs(): - fetch_song() - else: - Playlist.is_song_downloading = false - if Settings.is_debug: - update_song_list() - Settings.make_request2("/music/info", fetch_song_info_done, true) - - Settings.make_request2("/music/addQue", added_to_que_done, false) - - Settings.make_request2(url, fetch_song_done, true) - -func update_song_list() -> void: - print("update_song_list") - if Playlist.currently_playing_song >= 0: - soundtrack_label.text = Playlist.get_current_soundtrack_title() - song_label.text = Playlist.get_current_song_title() - Settings.delete_children(music_list) - for s: SongObject in Playlist.get_playlist(): - var new_song_list_object: SongListObject = song_list_object.instantiate() - new_song_list_object.song_object = s - new_song_list_object.gui_input.connect(song_object_clicked.bind(s.song_number)) - music_list.add_child(new_song_list_object) - -func song_object_clicked(event: InputEvent, song_no: int) -> void: - if (event is InputEventMouseButton && event.pressed && event.button_index == MOUSE_BUTTON_LEFT): - print("Clicked Song Object: ", Playlist.song_object_array[song_no].to_string()) - if Settings.character_select_open == false: - if Playlist.get_song(song_no) == null: - print("has no song, need to download") - var song_downloaded: Callable = func song_downloaded(body: PackedByteArray) -> void: - var sound: AudioStream = AudioStreamMP3.new() - sound.data = body - Playlist.add_song_to_song_object(song_no, sound) - music_player_container.play_song_object(song_no) - Settings.make_request2("/music?song=" + str(song_no), song_downloaded, true) - else: - print("song already downloaded") - music_player_container.play_song_object(song_no) - -func reset_playlist() -> void: - print("reset_playlist") - var playlist_reset_done: Callable = func playlist_reset_done() -> void: - Playlist.reset_playlist() - fetch_song_list_at_start() - fetch_song() - - Settings.make_request2("/music/reset", playlist_reset_done, false) - -func reset_points() -> void: - print("reset_points") - for player_objects: PlayerObject in Settings.player_array: - player_objects.reset_points() - load_players() - -func show_about() -> void: - print("show_about") - about_window.show_window() - -func show_shortcuts() -> void: - print("show_shortcuts") - shortcuts_window.visible = true - -func show_settings() -> void: - print("show_settings") - settings_window.visible = true - -func show_qr() -> void: - print("show_qr") - qr_window.show_window() - -func get_statistics() -> void: - print("get_statistics") - statistic_popup.visible = true - statistic_label.text = "Total amount of soundtracks in the playlist: " + str(soundtracks.size()) - -func sync_soundtracks() -> void: - print("sync_soundtracks") - sync_window.start_sync() - -func _on_sync_started() -> void: - print("_on_sync_started") - sync_button.disabled = false - sound_test_button.disabled = true - reset_playlist_button.disabled = true - show_answer_button.disabled = true - next_button.disabled = true - -func _on_sync_finished() -> void: - print("_on_sync_finished") - update_from_server() - sound_test_button.disabled = false - reset_playlist_button.disabled = false - show_answer_button.disabled = false - reset_points() - -func get_suggestion_list() -> void: - print("get_suggestion_list") - var populate_list: Callable = func populate_list(array) -> void: - if typeof(array) == TYPE_ARRAY: - soundtracks = [] - Settings.delete_children(inspiration_list) - soundtracks.append_array(array) - for soundtrack: String in soundtracks: - var inspiration_label: Label = Label.new() - inspiration_label.set_texture_filter(TextureFilter.TEXTURE_FILTER_NEAREST) - inspiration_label.add_theme_font_size_override("font_size", 20) - inspiration_label.text = soundtrack - inspiration_label.autowrap_mode = TextServer.AUTOWRAP_WORD - inspiration_list.add_child(inspiration_label) - else: - print("Unexpected data") - Settings.make_request2("/music/all", populate_list, true) - -func add_players() -> void: - print("add_players") - Settings.edit_players = !Settings.edit_players - add_player_container.visible = !add_player_container.visible - new_player_name_field.grab_focus() - -func add_player() -> void: - print("add_player") - if new_player_name_field.text != "" && Settings.player_array.size() < Settings.MAX_NUMBER_OF_PLAYERS: - var new_player_object: PlayerObject = PlayerObject.new(new_player_name_field.text, Settings.player_array.size()) - new_player_name_field.text = "" - Settings.player_array.append(new_player_object) - new_player_object.connect("first_point_triggerd", music_player_container._on_point_triggered.bind("first")) - new_player_object.connect("match_point_triggerd", music_player_container._on_point_triggered.bind("match")) - new_player_object.connect("winner_triggerd", _on_player_won.bind(new_player_object.id)) - new_player_object.connect("point_given_sound", _on_make_point_given_sound) - new_player_object.connect("player_point_given", _on_point_given.bind(new_player_object.id)) - new_player_object.connect("player_point_taken", _on_point_taken.bind(new_player_object.id)) - load_players() - -func load_players() -> void: - print("load_players") - Settings.delete_player_children(players_list_container) - var counter: int = 0 - for player_object: PlayerObject in Settings.player_array: - print(player_object.player_name) - - var new_player: Player = player.instantiate() - new_player.id = counter - - new_player.connect("change_character_clicked", _on_player_change_character_clicked.bind(new_player, player_object.player_name)) - new_player.connect("player_removed", _on_player_removed.bind(counter)) - - players_list_container.add_child(new_player) - counter += 1 - -func _on_point_given(player_given_point: int) -> void: - print("_on_point_given") - log_window.add_log_row(Settings.player_array[player_given_point].player_name + " got a point") - if Playlist.currently_playing_song >= 0: - Playlist.add_point(player_given_point) - update_song_list() - -func _on_make_point_given_sound() -> void: - music_player_container.play_sound_effect(preload("res://sounds/itemopen.wav")) - -func _on_point_taken(player_taken_point: int) -> void: - print("_on_point_taken") - log_window.add_log_row(Settings.player_array[player_taken_point].player_name + " lost a point") - music_player_container.play_sound_effect(preload("res://sounds/itemequip.wav")) - if Playlist.currently_playing_song >= 0: - Playlist.remove_point(player_taken_point) - update_song_list() - -func _on_player_won(winning_player_id: int) -> void: - print("_on_player_won") - winner_window.show_winner(winning_player_id) - music_player_container.play_song(preload("res://sounds/winning.mp3")) - Settings.add_to_stats = false - -func _on_player_removed(player_to_remove: int) -> void: - print("_on_player_removed ", player_to_remove) - Settings.player_array.remove_at(player_to_remove) - load_players() - Playlist.remove_player_from_list(player_to_remove) - update_song_list() - -func _on_player_change_character_clicked(new_player: Player, player_name: String) -> void: - print("_on_player_change_character_clicked") - current_player = new_player - print("Change character for: " + player_name) - new_character_select.show_grid(player_name) - -func _on_character_selected(file_name: String) -> void: - print("_on_character_selected") - character_select.visible = false - current_player.character_selected(file_name) - -func _on_new_character_selected(character_texture: ImageTexture) -> void: - print("_on_new_character_selected") - current_player.new_character_selected(character_texture) - -func show_search() -> void: - print("show_search") - if search_view.visible == false: - search_view.visible = true - else: - search_view.visible = false - - -###Local - -var local_path: String = '/Users/sebastian/ResilioSync/Sorterat_test/Metal Gear Solid 4 - Guns of the Patriots/2-16 Metal Gear Saga.mp3' - -func open() -> void: - fileDialog.popup() - -func _on_file_dialog_dir_selected(path: String) -> void: - print(path) - dir_contents(path) - -func dir_contents(path: String) -> void: - var dir: DirAccess = DirAccess.open(path) - if dir: - dir.list_dir_begin() - var file_name: String = dir.get_next() - songs.clear() - soundtracks = [] - while file_name != "": - if dir.current_is_dir(): - #print("Found directory: " + file_name) - soundtracks.append(file_name) - else: - #print("Found file: " + file_name) - if file_name.ends_with(".mp3"): - songs.append(path + "/" + file_name) - file_name = dir.get_next() - else: - print("An error occurred when trying to access the path.") - - for soundtrack: String in soundtracks: - var local_inspiration_label: Label = Label.new() - local_inspiration_label.text = soundtrack - inspiration_list.add_child(local_inspiration_label) diff --git a/MainWindow.tscn b/MainWindow.tscn deleted file mode 100644 index e4bfec1..0000000 --- a/MainWindow.tscn +++ /dev/null @@ -1,527 +0,0 @@ -[gd_scene format=3 uid="uid://xwq863o6uvsu"] - -[ext_resource type="Script" uid="uid://b8f4g15cas2j2" path="res://MainWindow.gd" id="1_eu0t5"] -[ext_resource type="PackedScene" uid="uid://b16on0oscg1bv" path="res://CharacterSelect.tscn" id="2_76kf4"] -[ext_resource type="Script" uid="uid://elgn7gkxgagq" path="res://InsperationScrollContainer.gd" id="2_gxtxm"] -[ext_resource type="Texture2D" uid="uid://bcfmpd7h512ef" path="res://icons/person_add_light.svg" id="5_31tjv"] -[ext_resource type="PackedScene" uid="uid://ds15cgsf8vpvc" path="res://MusicPlayer.tscn" id="5_emn36"] -[ext_resource type="PackedScene" uid="uid://cdy4kvemwaiom" path="res://new_character_select.tscn" id="6_dhrvg"] -[ext_resource type="Script" uid="uid://gf1bk6xbe3j" path="res://MusicListScrollContainer.gd" id="7_dj026"] -[ext_resource type="Script" uid="uid://cgakje8gb37tf" path="res://PlayerNameField.gd" id="7_qsdfy"] -[ext_resource type="Theme" uid="uid://rxexo3ur85as" path="res://LightGrayTheme.tres" id="7_wxbv6"] -[ext_resource type="PackedScene" uid="uid://bxydgil1yifps" path="res://SearchWindow.tscn" id="9_5ijvr"] -[ext_resource type="PackedScene" uid="uid://dpdem7pdxweb5" path="res://SyncWindow.tscn" id="10_yxw0b"] -[ext_resource type="PackedScene" uid="uid://dldpeo5y3l5hq" path="res://SettingsWindow.tscn" id="11_k62u5"] -[ext_resource type="PackedScene" uid="uid://bd1by80q1v27e" path="res://shortcutsWindow.tscn" id="13_8eebo"] -[ext_resource type="PackedScene" uid="uid://bijh5h5yrivm3" path="res://Log.tscn" id="14_26rwn"] -[ext_resource type="PackedScene" uid="uid://btupbowehiyyu" path="res://QRWindow.tscn" id="15_qr_window"] -[ext_resource type="PackedScene" uid="uid://cp54idaudb8dp" path="res://AboutWindow.tscn" id="16_about_window"] -[ext_resource type="PackedScene" uid="uid://cxlsmxnrp0agh" path="res://WinnerWindow.tscn" id="17_winner_window"] - -[sub_resource type="LabelSettings" id="LabelSettings_ychxr"] -font_size = 25 - -[sub_resource type="LabelSettings" id="LabelSettings_qspbu"] -font_size = 50 - -[sub_resource type="LabelSettings" id="LabelSettings_3m52w"] -font_size = 35 - -[sub_resource type="InputEventKey" id="InputEventKey_03bm3"] -device = -1 -alt_pressed = true -keycode = 65 -unicode = 63743 - -[sub_resource type="Shortcut" id="Shortcut_jafqj"] -events = [SubResource("InputEventKey_03bm3")] - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_qk7sj"] -bg_color = Color(0.180392, 0.180392, 0.180392, 1) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_k1ygi"] -bg_color = Color(0.180392, 0.180392, 0.180392, 1) - -[sub_resource type="InputEventKey" id="InputEventKey_06rl4"] -device = -1 -alt_pressed = true -keycode = 86 -unicode = 8249 - -[sub_resource type="Shortcut" id="Shortcut_a7fvb"] -events = [SubResource("InputEventKey_06rl4")] - -[sub_resource type="InputEventKey" id="InputEventKey_7dnqw"] -device = -1 -alt_pressed = true -keycode = 67 -unicode = 231 - -[sub_resource type="Shortcut" id="Shortcut_d6fml"] -events = [SubResource("InputEventKey_7dnqw")] - -[sub_resource type="InputEventKey" id="InputEventKey_ujjlu"] -device = -1 -alt_pressed = true -keycode = 83 - -[sub_resource type="Shortcut" id="Shortcut_fbju4"] -events = [SubResource("InputEventKey_ujjlu")] - -[node name="Control" type="Control" unique_id=407149696] -layout_mode = 3 -anchors_preset = 0 -script = ExtResource("1_eu0t5") - -[node name="TopPanelContainer" type="PanelContainer" parent="." unique_id=663404711] -layout_mode = 0 -offset_right = 1920.0 -offset_bottom = 56.0 - -[node name="Title" type="Label" parent="." unique_id=1187907861] -texture_filter = 1 -layout_mode = 0 -offset_left = 64.0 -offset_top = 12.0 -offset_right = 369.0 -offset_bottom = 50.0 -size_flags_horizontal = 0 -text = "Music Player Randomizer" -label_settings = SubResource("LabelSettings_ychxr") - -[node name="Open" type="Button" parent="." unique_id=165052329] -visible = false -layout_mode = 0 -offset_left = 1296.0 -offset_top = 66.0 -offset_right = 1346.0 -offset_bottom = 97.0 -text = "Open" - -[node name="FileDialog" type="FileDialog" parent="." unique_id=485771559] -title = "Open a Directory" -initial_position = 2 -size = Vector2i(616, 400) -ok_button_text = "Select Current Folder" -file_mode = 2 -access = 2 -show_hidden_files = true - -[node name="MusicListPanel" type="PanelContainer" parent="." unique_id=2011292375] -layout_mode = 0 -offset_left = 64.0 -offset_top = 384.0 -offset_right = 1152.0 -offset_bottom = 896.0 -theme = ExtResource("7_wxbv6") - -[node name="ScrollContainer" type="ScrollContainer" parent="MusicListPanel" unique_id=707750459] -layout_mode = 2 -follow_focus = true -script = ExtResource("7_dj026") - -[node name="MusicList" type="VBoxContainer" parent="MusicListPanel/ScrollContainer" unique_id=373398568] -texture_filter = 1 -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 3 -theme_override_constants/separation = 10 - -[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=498283761] -texture_filter = 1 -layout_mode = 0 -offset_left = 1216.0 -offset_top = 128.0 -offset_right = 1857.0 -offset_bottom = 512.0 -horizontal_scroll_mode = 0 -vertical_scroll_mode = 3 -script = ExtResource("2_gxtxm") - -[node name="VBoxContainer" type="VBoxContainer" parent="ScrollContainer" unique_id=1215162949] -texture_filter = 1 -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 3 - -[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=42468498] -layout_mode = 0 -offset_left = 64.0 -offset_top = 64.0 -offset_right = 1152.0 -offset_bottom = 384.0 - -[node name="SoundtrackLabel" type="Label" parent="VBoxContainer" unique_id=173147793] -texture_filter = 1 -custom_minimum_size = Vector2(1, 1) -layout_mode = 2 -label_settings = SubResource("LabelSettings_qspbu") -horizontal_alignment = 1 -autowrap_mode = 2 - -[node name="SongLabel" type="Label" parent="VBoxContainer" unique_id=1687490027] -texture_filter = 1 -custom_minimum_size = Vector2(1, 1) -layout_mode = 2 -label_settings = SubResource("LabelSettings_3m52w") -horizontal_alignment = 1 -autowrap_mode = 2 - -[node name="CharacterSelect" parent="." unique_id=2079506802 instance=ExtResource("2_76kf4")] -visible = false -top_level = true -layout_mode = 0 -offset_left = 616.0 -offset_top = 264.0 -offset_right = 776.0 -offset_bottom = 424.0 - -[node name="MusicPlayer" parent="." unique_id=1990459832 instance=ExtResource("5_emn36")] -layout_mode = 0 -anchors_preset = 0 -anchor_left = 0.0 -anchor_top = 0.0 -anchor_right = 0.0 -anchor_bottom = 0.0 -offset_left = 65.0 -offset_top = 992.0 -offset_right = 1856.0 -offset_bottom = 1024.0 -grow_horizontal = 1 -grow_vertical = 1 - -[node name="Players" type="PanelContainer" parent="." unique_id=1708375945] -layout_mode = 0 -offset_left = 1216.0 -offset_top = 576.0 -offset_right = 1857.0 -offset_bottom = 977.0 - -[node name="VBoxContainer" type="VBoxContainer" parent="Players" unique_id=270084211] -layout_mode = 2 - -[node name="HBoxContainer" type="HBoxContainer" parent="Players/VBoxContainer" unique_id=513047023] -custom_minimum_size = Vector2(2.08165e-12, 40) -layout_mode = 2 - -[node name="Label" type="Label" parent="Players/VBoxContainer/HBoxContainer" unique_id=1331357386] -texture_filter = 1 -custom_minimum_size = Vector2(2.08165e-12, 40) -layout_mode = 2 -size_flags_horizontal = 3 -size_flags_vertical = 1 -text = "Players" -horizontal_alignment = 1 -vertical_alignment = 1 - -[node name="AddPlayersButton" type="TextureButton" parent="Players/VBoxContainer/HBoxContainer" unique_id=634410148] -texture_filter = 1 -custom_minimum_size = Vector2(2.08165e-12, 40) -layout_mode = 2 -size_flags_horizontal = 8 -focus_mode = 0 -action_mode = 0 -shortcut = SubResource("Shortcut_jafqj") -texture_normal = ExtResource("5_31tjv") - -[node name="AddPlayerContainer" type="HBoxContainer" parent="Players/VBoxContainer" unique_id=1371948739] -visible = false -custom_minimum_size = Vector2(2.08165e-12, 40) -layout_mode = 2 - -[node name="Panel" type="Panel" parent="Players/VBoxContainer/AddPlayerContainer" unique_id=1569854056] -layout_mode = 2 -size_flags_horizontal = 3 -theme_override_styles/panel = SubResource("StyleBoxFlat_qk7sj") - -[node name="PlayerNameField" type="TextEdit" parent="Players/VBoxContainer/AddPlayerContainer" unique_id=332535327] -custom_minimum_size = Vector2(2.08165e-12, 40) -layout_mode = 2 -size_flags_horizontal = 3 -placeholder_text = "Player name" -script = ExtResource("7_qsdfy") - -[node name="AddPlayerButton" type="Button" parent="Players/VBoxContainer/AddPlayerContainer" unique_id=245001313] -texture_filter = 1 -custom_minimum_size = Vector2(2.08165e-12, 40) -layout_mode = 2 -text = "Add" - -[node name="Panel2" type="Panel" parent="Players/VBoxContainer/AddPlayerContainer" unique_id=60215448] -custom_minimum_size = Vector2(2.08165e-12, 40) -layout_mode = 2 -size_flags_horizontal = 3 -theme_override_styles/panel = SubResource("StyleBoxFlat_k1ygi") - -[node name="ScrollContainer" type="ScrollContainer" parent="Players/VBoxContainer" unique_id=1000000001] -layout_mode = 2 -size_flags_vertical = 15 - -[node name="PlayersListVBoxContainer" type="VBoxContainer" parent="Players/VBoxContainer/ScrollContainer" unique_id=1000000002] -layout_mode = 2 -size_flags_vertical = 15 - -[node name="ResetPlaylistButton" type="Button" parent="." unique_id=1754155620] -texture_filter = 1 -layout_mode = 0 -offset_left = 1360.0 -offset_top = 64.0 -offset_right = 1471.0 -offset_bottom = 95.0 -focus_mode = 0 -action_mode = 0 -text = "Reset playlist" - -[node name="ResetPointsButton" type="Button" parent="." unique_id=942880776] -texture_filter = 1 -layout_mode = 0 -offset_left = 1488.0 -offset_top = 64.0 -offset_right = 1599.0 -offset_bottom = 95.0 -focus_mode = 0 -action_mode = 0 -text = "Reset points" - -[node name="SyncButton" type="Button" parent="." unique_id=1228313556] -texture_filter = 1 -layout_mode = 0 -offset_left = 1616.0 -offset_top = 64.0 -offset_right = 1759.0 -offset_bottom = 95.0 -focus_mode = 0 -action_mode = 0 -text = "Sync soundtracks" - -[node name="SoundTestButton" type="Button" parent="." unique_id=1332971879] -texture_filter = 1 -layout_mode = 0 -offset_left = 1776.0 -offset_top = 64.0 -offset_right = 1887.0 -offset_bottom = 95.0 -focus_mode = 0 -action_mode = 0 -text = "Sound test" - -[node name="ShowAnswerButton" type="Button" parent="." unique_id=415388039] -texture_filter = 1 -layout_mode = 0 -offset_left = 65.0 -offset_top = 944.0 -offset_right = 176.0 -offset_bottom = 975.0 -focus_mode = 0 -action_mode = 0 -shortcut = SubResource("Shortcut_a7fvb") -text = "Show answer" - -[node name="NextButton" type="Button" parent="." unique_id=1744703488] -texture_filter = 1 -layout_mode = 0 -offset_left = 193.0 -offset_top = 944.0 -offset_right = 368.0 -offset_bottom = 975.0 -focus_mode = 0 -disabled = true -action_mode = 0 -shortcut = SubResource("Shortcut_d6fml") -text = "Randomize new track" - -[node name="SearchButton" type="Button" parent="." unique_id=1782453581] -texture_filter = 1 -layout_mode = 0 -offset_left = 1354.0 -offset_top = 8.0 -offset_right = 1424.0 -offset_bottom = 48.0 -focus_mode = 0 -action_mode = 0 -shortcut = SubResource("Shortcut_fbju4") -text = "Search" - -[node name="SettingsButton" type="Button" parent="." unique_id=1193400323] -texture_filter = 1 -layout_mode = 0 -offset_left = 1442.0 -offset_top = 8.0 -offset_right = 1512.0 -offset_bottom = 48.0 -focus_mode = 0 -action_mode = 0 -text = "Settings" - -[node name="StatisticsButton" type="Button" parent="." unique_id=2045021269] -texture_filter = 1 -layout_mode = 0 -offset_left = 1530.0 -offset_top = 8.0 -offset_right = 1600.0 -offset_bottom = 48.0 -focus_mode = 0 -action_mode = 0 -text = "Statistics" - -[node name="ShortcutsButton" type="Button" parent="." unique_id=2066498226] -texture_filter = 1 -layout_mode = 0 -offset_left = 1626.0 -offset_top = 8.0 -offset_right = 1696.0 -offset_bottom = 48.0 -focus_mode = 0 -action_mode = 0 -text = "Shortcuts" - -[node name="AboutButton" type="Button" parent="." unique_id=1057035253] -texture_filter = 1 -layout_mode = 0 -offset_left = 1722.0 -offset_top = 8.0 -offset_right = 1792.0 -offset_bottom = 48.0 -focus_mode = 0 -action_mode = 0 -text = "About" - -[node name="QRButton" type="Button" parent="." unique_id=2021908559] -texture_filter = 1 -layout_mode = 0 -offset_left = 1802.0 -offset_top = 8.0 -offset_right = 1872.0 -offset_bottom = 48.0 -focus_mode = 0 -action_mode = 0 -text = "QR" - -[node name="Search" parent="." unique_id=1661250375 instance=ExtResource("9_5ijvr")] -visible = false -layout_mode = 1 -offset_left = 56.0 -offset_top = 96.0 -offset_right = 56.0 -offset_bottom = 96.0 - -[node name="SyncWindow" parent="." unique_id=182499699 instance=ExtResource("10_yxw0b")] -unique_name_in_owner = true -visible = false -layout_mode = 1 -offset_left = 401.0 -offset_top = 153.0 -offset_right = 401.0 -offset_bottom = 153.0 - -[node name="SettingsWindow" parent="." unique_id=1323246978 instance=ExtResource("11_k62u5")] -visible = false -layout_mode = 1 -offset_left = 720.0 -offset_top = 153.0 -offset_right = 720.0 -offset_bottom = 153.0 - -[node name="QRWindow" parent="." unique_id=589733821 instance=ExtResource("15_qr_window")] -unique_name_in_owner = true -visible = false -layout_mode = 1 -offset_left = 720.0 -offset_top = 153.0 -offset_right = 720.0 -offset_bottom = 153.0 - -[node name="NewCharacterSelect" parent="." unique_id=1679856322 instance=ExtResource("6_dhrvg")] -unique_name_in_owner = true -visible = false -layout_mode = 1 -offset_left = 420.0 -offset_top = 165.0 -offset_right = 1572.0 -offset_bottom = 805.0 - -[node name="ShortcutsWindow" parent="." unique_id=1513416242 instance=ExtResource("13_8eebo")] -unique_name_in_owner = true -visible = false -layout_mode = 1 -offset_left = 562.0 -offset_top = 160.0 -offset_right = 1262.0 -offset_bottom = 860.0 - -[node name="StatisticsPopupPanel" type="PopupPanel" parent="." unique_id=298311407] -oversampling_override = 1.0 -initial_position = 2 -size = Vector2i(450, 100) - -[node name="Label" type="Label" parent="StatisticsPopupPanel" unique_id=548316493] -texture_filter = 1 -offset_left = 4.0 -offset_top = 4.0 -offset_right = 446.0 -offset_bottom = 96.0 -text = "Statistics" -horizontal_alignment = 1 - -[node name="StatisticsLabel" type="Label" parent="StatisticsPopupPanel" unique_id=58226477] -texture_filter = 1 -offset_left = 4.0 -offset_top = 4.0 -offset_right = 446.0 -offset_bottom = 96.0 -text = "Total amount of soundtracks in the playlist: 9000" -horizontal_alignment = 1 -vertical_alignment = 1 - -[node name="AboutWindow" parent="." unique_id=141261008 instance=ExtResource("16_about_window")] -unique_name_in_owner = true -visible = false -layout_mode = 1 -offset_left = 720.0 -offset_top = 153.0 -offset_right = 720.0 -offset_bottom = 153.0 - -[node name="WinnerWindow" parent="." unique_id=999999999 instance=ExtResource("17_winner_window")] -unique_name_in_owner = true -visible = false -layout_mode = 1 -offset_left = 720.0 -offset_top = 153.0 -offset_right = 720.0 -offset_bottom = 153.0 - -[node name="RepeatSongCheckButton" type="CheckButton" parent="." unique_id=776835053] -layout_mode = 0 -offset_left = 64.0 -offset_top = 1032.0 -offset_right = 208.0 -offset_bottom = 1063.0 -focus_mode = 0 -button_pressed = true -action_mode = 0 -text = "Auto repeat" - -[node name="DebugLabel" type="Label" parent="." unique_id=1959221257] -visible = false -layout_mode = 0 -offset_left = 974.0 -offset_top = 904.0 -offset_right = 1014.0 -offset_bottom = 927.0 -scale = Vector2(3, 3) -text = "DEBUG" -horizontal_alignment = 1 -vertical_alignment = 1 -autowrap_mode = 2 - -[node name="Log" parent="." unique_id=1289881388 instance=ExtResource("14_26rwn")] -unique_name_in_owner = true -visible = false -layout_mode = 1 -offset_left = 562.0 -offset_top = 132.0 -offset_right = 1262.0 -offset_bottom = 832.0 - -[connection signal="dir_selected" from="FileDialog" to="." method="_on_file_dialog_dir_selected"] diff --git a/MusicListScrollContainer.gd b/MusicListScrollContainer.gd deleted file mode 100644 index 0d63ea0..0000000 --- a/MusicListScrollContainer.gd +++ /dev/null @@ -1,8 +0,0 @@ -extends ScrollContainer - -var max_value = 0; - -func _draw(): - if max_value != self.get_v_scroll_bar().max_value: - max_value = self.get_v_scroll_bar().max_value - self.scroll_vertical = max_value diff --git a/Player.gd b/Player.gd deleted file mode 100644 index 5a29adb..0000000 --- a/Player.gd +++ /dev/null @@ -1,79 +0,0 @@ -class_name Player -extends Control - -@onready -var player_name_field := $HBoxContainer/Name - -@onready -var add_button := $HBoxContainer/AddPoint - -@onready -var minus_button := $HBoxContainer/RemovePoint - -@onready -var character := $HBoxContainer/Character - -@onready -var remove_player := $HBoxContainer/RemovePlayer - -signal change_character_clicked -signal player_removed - -@export -var id: int - -# Called when the node enters the scene tree for the first time. -func _ready() -> void: - add_button.pressed.connect(add_point) - add_button.set_shortcut(create_button_shortcut(id, false)) - minus_button.pressed.connect(minus_point) - minus_button.set_shortcut(create_button_shortcut(id, true)) - character.pressed.connect(change_character) - remove_player.pressed.connect(func() -> void: player_removed.emit()) - update_score() - set_player_character() - -func _process(delta: float) -> void: - remove_player.visible = Settings.edit_players - -func create_button_shortcut(scancode : int, ctrl_pressed: bool) -> Shortcut: - var button_shortcut: Shortcut = Shortcut.new() - var button_event : InputEventKey = InputEventKey.new() - button_event.keycode = scancode + 49 - button_event.alt_pressed = true - button_event.ctrl_pressed = ctrl_pressed - button_shortcut.events.append(button_event) - return button_shortcut - -func add_point() -> void: - Settings.player_array[id].add_point() - update_score() - -func minus_point() -> void: - Settings.player_array[id].minus_point() - update_score() - -func change_character() -> void: - change_character_clicked.emit() - -func character_selected(file_name: String) -> void: - print("Back in player list with: " + file_name) - Settings.player_array[id].character = load("res://characters/" + file_name) - set_player_character() - -func new_character_selected(character_texture: ImageTexture) -> void: - print("Back in player list with new") - Settings.player_array[id].character = character_texture - set_player_character() - -func set_player_character() -> void: - character.custom_minimum_size = Vector2(80, 40) - character.ignore_texture_size = true - character.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED - character.texture_normal = Settings.player_array[id].character - -func update_score() -> void: - if OS.has_feature("debug"): - player_name_field.text = str(Settings.player_array[id].id) + " " + Settings.player_array[id].player_name + ": " + str(Settings.player_array[id].player_score) - else: - player_name_field.text = Settings.player_array[id].player_name + ": " + str(Settings.player_array[id].player_score) diff --git a/PlayerObject.gd b/PlayerObject.gd deleted file mode 100644 index 7ff496c..0000000 --- a/PlayerObject.gd +++ /dev/null @@ -1,52 +0,0 @@ -extends Node -class_name PlayerObject - -signal first_point_triggerd -signal match_point_triggerd -signal winner_triggerd -signal point_given_sound -signal player_point_given -signal player_point_taken - -@export -var id: int - -@export -var player_name: String - -@export -var player_score: int - -var is_first_point: bool = true - -var character: Texture = load("res://noCharacter.png") - -func _init(new_name: String, new_id: int) -> void: - player_name = new_name - id = new_id - player_score = 0 - -func add_point() -> void: - if player_score < Settings.winning_score: - player_score += 1 - player_point_given.emit() - if player_score == 1 && is_first_point: - is_first_point = false - first_point_triggerd.emit() - elif player_score == Settings.winning_score - 1: - match_point_triggerd.emit() - elif player_score == Settings.winning_score: - winner_triggerd.emit() - else: - point_given_sound.emit() - -func minus_point() -> void: - if player_score > 0: - player_score -= 1 - player_point_taken.emit() - if player_score == 0: - is_first_point = true - -func reset_points() -> void: - player_score = 0 - is_first_point = true diff --git a/QRWindow.gd b/QRWindow.gd deleted file mode 100644 index 52a02db..0000000 --- a/QRWindow.gd +++ /dev/null @@ -1,41 +0,0 @@ -extends Control - -@onready -var qr_panel: Panel = $QRPanel - -@onready -var qr_code_rect: QRCodeRect = $QRPanel/QRCodeRect - -@onready -var url_label: Label = $QRPanel/URLLabel - - -func _ready() -> void: - qr_code_rect.light_module_color = Color.WHITE - qr_code_rect.dark_module_color = Color.BLACK - qr_code_rect.quiet_zone_size = 4 - - # Generate QR code for the search URL - var url: String = Settings.default_path + "/search" - qr_code_rect.data = url - url_label.text = qr_code_rect.data - print("QR code generated for: ", url) - -func show_window() -> void: - visible = true - # Regenerate QR code in case settings changed - var url: String = Settings.default_path + "/search" - qr_code_rect.data = url - url_label.text = qr_code_rect.data - - -func _input(event: InputEvent) -> void: - if self.visible == true: - if event is InputEventMouseButton && event.is_pressed(): - var evLocal: InputEvent = make_input_local(event) - if !Rect2(Vector2(0, 0), qr_panel.size).has_point(evLocal.position): - self.visible = false - - -func hide_window() -> void: - visible = false diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..5ea1bd5 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,29 @@ +# Releasing + +Releases are made from the version in `project.godot` +(**Project > Project Settings > Application > Config > Version**). The game shows the +same version in the About window. + +1. Bump the version, e.g. from `1.9.0` to `1.10.0`, and add a `WHATS_NEW` entry in + `popups/about_popup.gd`. +2. Merge to `main` and push. + +The `Build` workflow (`.gitea/workflows/gitea-build.yaml`) then: + +1. runs the tests, +2. exports the Windows and Linux builds, and packages Linux as + `MusicPlayer__linux.tar.gz` with the icon, an app menu entry and `install.sh` + (Linux programs can't carry their own icon the way a Windows .exe does), +3. if no tag exists for the version yet: uploads the builds to the package registry + under `MusicPlayer//`, and creates the `` tag and Gitea release + with the builds attached. + +Pushes that don't change the version, and pushes to `develop`, only test and export. +Don't create tags or `1.x.y` branches by hand; the workflow makes the tag. + +## Installing on Linux + +Unpack `MusicPlayer__linux.tar.gz` and run `./install.sh` inside the +`MusicPlayer` folder. It installs the game to `~/.local/bin` and adds it, with its icon, +to the app menu. The bare `MusicPlayer_.x86_64` is still published for anyone +who just wants to run the file. diff --git a/Settings.gd b/Settings.gd deleted file mode 100644 index 8b28d29..0000000 --- a/Settings.gd +++ /dev/null @@ -1,101 +0,0 @@ -extends Node - -const MAX_NUMBER_OF_PLAYERS: int = 7 - -var default_path: String = "https://music.sanplex.xyz" -var selected_server: int = 0 - -var is_local: bool = false - -var is_debug: bool = false - -var stop_after_current: bool = true -var auto_repeat_song: bool = true -var hide_next_track: bool = true -var add_to_stats: bool = true -var use_low_played_mode: bool = false -var winning_score: int = 20 -var fullscreen: bool = false -var play_local: bool = false -var inspiration_list_speed: int = 1 -var hide_for_seconds: float = 5.0 -var hide_beginning: bool = false -var hide_length: bool = false -var hide_ticks: bool = false - -var player_array: Array[PlayerObject] -var edit_players: bool = false -var currently_syncing: bool = false -var character_select_open: bool = false - -var version: String = "1.9.0" - -func make_request2(address: String, func_name: Callable, expect_data: bool) -> void: - var error_handling: Callable = func(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: - print("request done to address: ", default_path + address + " response_code: ", response_code) - if response_code == 200: - print("func_name: ", func_name.get_method()) - if !expect_data: - func_name.call() - else: - var json: JSON = JSON.new() - var error: int = json.parse(body.get_string_from_utf8()) - if error == OK: - var data_received = json.get_data() - print("data_received type: ", type_string(typeof(data_received))) - if typeof(data_received) == TYPE_ARRAY: - func_name.call(data_received) - elif func_name != null: - func_name.call(data_received) - else: - print("data_received type: ", type_string(typeof(body))) - func_name.call(body) - - var http_request: HTTPRequest = HTTPRequest.new() - add_child(http_request) - http_request.request_completed.connect(error_handling) - - # Perform a GET request. The URL below returns JSON as of writing. - print("address: ", default_path + address) - var request_error: int = http_request.request(default_path + address) - if request_error != OK: - push_error("An error occurred in the HTTP request.") - -func make_request3(address: String) -> void: - var error_handling: Callable = func(_result: int, response_code: int, _headers: PackedStringArray, _body: PackedByteArray) -> void: - print("request done to address: ", default_path + address + " response_code: ", response_code) - if response_code != 200: - print("Error: " + str(response_code)) - - var http_request: HTTPRequest = HTTPRequest.new() - add_child(http_request) - http_request.request_completed.connect(error_handling) - # Perform a GET request. The URL below returns JSON as of writing. - var request_error: int = http_request.request(default_path + address) - if request_error != OK: - push_error("An error occurred in the HTTP request.") - -func make_put_request(address: String) -> void: - var error_handling: Callable = func(_result: int, response_code: int, _headers: PackedStringArray, _body: PackedByteArray) -> void: - print("request done to address: ", default_path + address + " response_code: ", response_code) - if response_code != 200: - print("Error: " + str(response_code)) - - var http_request: HTTPRequest = HTTPRequest.new() - add_child(http_request) - http_request.request_completed.connect(error_handling) - # Perform a GET request. The URL below returns JSON as of writing. - var headers: PackedStringArray = ["Content-Type: application/json"] - var request_error: int = http_request.request(default_path + address, headers, HTTPClient.METHOD_PUT) - if request_error != OK: - push_error("An error occurred in the HTTP request.") - -func delete_children(node: Node) -> void: - for n: Node in node.get_children(): - node.remove_child(n) - n.queue_free() - -func delete_player_children(node: Node) -> void: - for n: Node in node.get_children(): - node.remove_child(n) - n.queue_free() diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..6e50dff --- /dev/null +++ b/TESTING.md @@ -0,0 +1,63 @@ +# Testing + +Tests use [GdUnit4](https://github.com/godot-gdunit-labs/gdUnit4) 6.2.1, committed in +`addons/gdUnit4/`. The whole suite runs headless in about 10 seconds. + +## Running the tests + +```bash +./run_tests.sh # everything +./run_tests.sh res://tests/unit # only unit tests +./run_tests.sh res://tests/unit/test_search.gd # one suite +``` + +The script finds Godot through `$GODOT`, then `godot` on your PATH, then +`/Applications/Godot.app` or `/Applications/Godot_mono.app`. Reports are written to +`reports/` (ignored by git). + +In the editor, open the GdUnit4 panel or right-click the `tests` folder and choose +**Run Tests**. + +CI runs the suite in the `test` job of `.gitea/workflows/gitea-build.yaml` on every push +and pull request to `main` and `develop`. The `build` job only starts once the tests +pass. + +## Layout + +| Folder | Contents | +|---|---| +| `tests/unit/` | Logic without scenes: playlist, songs, player scoring, time formatting, search, the server client | +| `tests/integration/` | Real scenes (`MainWindow.tscn`, `SyncPopup.tscn`) run against a fake server, including the point keybinds | +| `tests/fakes/` | `FakeHttpTransport`, `AutoloadSnapshot` and the `MainWindowTestSuite` base class | +| `tests/data/` | Files the fake server sends, e.g. a short MP3 | + +## Faking the MusicServer + +All server calls go through the `MusicServerClient` autoload, which sends requests with +a swappable `transport`. Tests install a `FakeHttpTransport` that answers from canned +responses and records what was requested: + +```gdscript +var server: FakeHttpTransport = FakeHttpTransport.new().install() +server.respond_json("/music/list", [{"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Frog's Theme"}]) +server.respond_bytes("/music/rand", mp3_bytes) + +# ... run the code under test ... + +assert_int(server.count("/music/rand")).is_equal(1) +server.uninstall() +``` + +Paths without a response get a 404, so the code under test sees a failed request. + +## Writing tests + +- Settings and Playlist are global. Tests that change them restore them afterwards, either + by hand or with `AutoloadSnapshot.take([Settings, Playlist])` and `restore()`. +- Main window tests extend `MainWindowTestSuite`, which starts the window against the fake + server and has helpers such as `_add_player()`, `_remove_player()` and + `_give_point_with_keybind()`. +- Player ids are UUID strings that never change. A player's position in + `Settings.player_array` only decides their keybind (Alt+1 for the first row and so on), + so tests check both: that points land on the right player id, and that keybinds follow + the rows when players are added or removed. diff --git a/addons/gdUnit4/.gitignore b/addons/gdUnit4/.gitignore new file mode 100644 index 0000000..2fee1e1 --- /dev/null +++ b/addons/gdUnit4/.gitignore @@ -0,0 +1,4 @@ +# GdUnit4 specific ignores +*.gdunit +*.gdunit.* +/test_results.* diff --git a/addons/gdUnit4/LICENSE b/addons/gdUnit4/LICENSE new file mode 100644 index 0000000..8c60d13 --- /dev/null +++ b/addons/gdUnit4/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Mike Schulze + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/addons/gdUnit4/bin/GdUnitCmdTool.gd b/addons/gdUnit4/bin/GdUnitCmdTool.gd new file mode 100644 index 0000000..cae9138 --- /dev/null +++ b/addons/gdUnit4/bin/GdUnitCmdTool.gd @@ -0,0 +1,21 @@ +#!/usr/bin/env -S godot -s +extends SceneTree + + +var _cli_runner: GdUnitTestCIRunner + + +func _initialize() -> void: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED) + _cli_runner = GdUnitTestCIRunner.new() + root.add_child(_cli_runner) + + +# do not use print statements on _finalize it results in random crashes +func _finalize() -> void: + queue_delete(_cli_runner) + if OS.is_stdout_verbose(): + prints("Finallize ..") + prints("-Orphan nodes report-----------------------") + Window.print_orphan_nodes() + prints("Finallize .. done") diff --git a/addons/gdUnit4/bin/GdUnitCmdTool.gd.uid b/addons/gdUnit4/bin/GdUnitCmdTool.gd.uid new file mode 100644 index 0000000..4e6b877 --- /dev/null +++ b/addons/gdUnit4/bin/GdUnitCmdTool.gd.uid @@ -0,0 +1 @@ +uid://bipm0dv6p07il diff --git a/addons/gdUnit4/bin/GdUnitCopyLog.gd b/addons/gdUnit4/bin/GdUnitCopyLog.gd new file mode 100644 index 0000000..8b22805 --- /dev/null +++ b/addons/gdUnit4/bin/GdUnitCopyLog.gd @@ -0,0 +1,167 @@ +#!/usr/bin/env -S godot -s +extends MainLoop + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +# gdlint: disable=max-line-length +const LOG_FRAME_TEMPLATE = """ + + + + + + Godot Logging + + + + +
+${content} +
+ + +""" + +const NO_LOG_MESSAGE = """ +

No logging available!

+
+

In order for logging to take place, you must activate the Activate file logging option in the project settings.

+

You can enable the logging under: +Project Settings > Debug > File Logging > Enable File Logging in the project settings.

+""" + +#warning-ignore-all:return_value_discarded +var _cmd_options := CmdOptions.new([ + CmdOption.new( + "-rd, --report-directory", + "-rd ", + "Specifies the output directory in which the reports are to be written. The default is res://reports/.", + TYPE_STRING, + true + ) + ]) + + +var _report_root_path: String +var _current_report_path: String +var _debug_cmd_args := PackedStringArray() + + +func _init() -> void: + set_report_directory(GdUnitFileAccess.current_dir() + "reports") + set_current_report_path() + + +func _process(_delta: float) -> bool: + # check if reports exists + if not reports_available(): + prints("no reports found") + return true + + # only process if godot logging is enabled + if not GdUnitSettings.is_log_enabled(): + write_report(NO_LOG_MESSAGE, "") + return true + + # parse possible custom report path, + var cmd_parser := CmdArgumentParser.new(_cmd_options, "GdUnitCmdTool.gd") + # ignore erros and exit quitly + if cmd_parser.parse(get_cmdline_args(), true).is_error(): + return true + CmdCommandHandler.new(_cmd_options).register_cb("-rd", set_report_directory) + + var godot_log_file := scan_latest_godot_log() + var result := read_log_file_content(godot_log_file) + if result.is_error(): + write_report(result.error_message(), godot_log_file) + return true + write_report(result.value_as_string(), godot_log_file) + return true + + +func set_current_report_path() -> void: + # scan for latest report directory + var iteration := GdUnitFileAccess.find_last_path_index( + _report_root_path, GdUnitConstants.REPORT_DIR_PREFIX + ) + _current_report_path = "%s/%s%d" % [_report_root_path, GdUnitConstants.REPORT_DIR_PREFIX, iteration] + + +func set_report_directory(path: String) -> void: + _report_root_path = path + + +func get_log_report_html() -> String: + return _current_report_path + "/godot_report_log.html" + + +func reports_available() -> bool: + return DirAccess.dir_exists_absolute(_report_root_path) + + +func scan_latest_godot_log() -> String: + var path := GdUnitSettings.get_log_path().get_base_dir() + var files_sorted := Array() + for file in GdUnitFileAccess.scan_dir(path): + var file_name := "%s/%s" % [path, file] + files_sorted.append(file_name) + # sort by name, the name contains the timestamp so we sort at the end by timestamp + files_sorted.sort() + return files_sorted.back() + + +func read_log_file_content(log_file: String) -> GdUnitResult: + var file := FileAccess.open(log_file, FileAccess.READ) + if file == null: + return GdUnitResult.error( + "Can't find log file '%s'. Error: %s" + % [log_file, error_string(FileAccess.get_open_error())] + ) + var content := "
" + file.get_as_text()
+	# patch out console format codes
+	for color_index in range(0, 256):
+		var to_replace := "[38;5;%dm" % color_index
+		content = content.replace(to_replace, "")
+	content += "
" + content = content\ + .replace("", "")\ + .replace(GdUnitCSIMessageWriter.CSI_BOLD, "")\ + .replace(GdUnitCSIMessageWriter.CSI_ITALIC, "")\ + .replace(GdUnitCSIMessageWriter.CSI_UNDERLINE, "") + return GdUnitResult.success(content) + + +func write_report(content: String, godot_log_file: String) -> GdUnitResult: + var file := FileAccess.open(get_log_report_html(), FileAccess.WRITE) + if file == null: + return GdUnitResult.error( + "Can't open to write '%s'. Error: %s" + % [get_log_report_html(), error_string(FileAccess.get_open_error())] + ) + var report_html := LOG_FRAME_TEMPLATE.replace("${content}", content) + file.store_string(report_html) + _update_index_html(godot_log_file) + return GdUnitResult.success(file) + + +func _update_index_html(godot_log_file: String) -> void: + var index_path := "%s/index.html" % _current_report_path + var index_file := FileAccess.open(index_path, FileAccess.READ_WRITE) + if index_file == null: + push_error( + "Can't add log path '%s' to `%s`. Error: %s" + % [godot_log_file, index_path, error_string(FileAccess.get_open_error())] + ) + return + var content := index_file.get_as_text()\ + .replace("${log_report}", get_log_report_html())\ + .replace("${godot_log_file}", godot_log_file) + # overide it + index_file.seek(0) + index_file.store_string(content) + + +func get_cmdline_args() -> PackedStringArray: + if _debug_cmd_args.is_empty(): + return OS.get_cmdline_args() + return _debug_cmd_args diff --git a/addons/gdUnit4/bin/GdUnitCopyLog.gd.uid b/addons/gdUnit4/bin/GdUnitCopyLog.gd.uid new file mode 100644 index 0000000..6dc43a1 --- /dev/null +++ b/addons/gdUnit4/bin/GdUnitCopyLog.gd.uid @@ -0,0 +1 @@ +uid://cyft2xqn77ffl diff --git a/addons/gdUnit4/plugin.cfg b/addons/gdUnit4/plugin.cfg new file mode 100644 index 0000000..dcf0f43 --- /dev/null +++ b/addons/gdUnit4/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="gdUnit4" +description="Unit Testing Framework for Godot Scripts" +author="Mike Schulze" +version="6.2.1" +script="plugin.gd" diff --git a/addons/gdUnit4/plugin.gd b/addons/gdUnit4/plugin.gd new file mode 100644 index 0000000..d72534c --- /dev/null +++ b/addons/gdUnit4/plugin.gd @@ -0,0 +1,90 @@ +@tool +extends EditorPlugin + +var _gd_inspector: Control +var _gd_console: Control +var _filesystem_context_menu: EditorContextMenuPlugin +var _editor_context_menu: EditorContextMenuPlugin +var _editor_code_context_menu: EditorContextMenuPlugin + + +func _enter_tree() -> void: + var inferred_declaration := GdUnitSettings.validate_is_inferred_declaration_enabled() + if inferred_declaration.is_error(): + printerr(inferred_declaration.error_message()) + printerr("Loading GdUnit4 Plugin failed.") + return + + if check_running_in_test_env(): + @warning_ignore("return_value_discarded") + GdUnitCSIMessageWriter.new().prints_warning("It was recognized that GdUnit4 is running in a test environment, therefore the GdUnit4 plugin will not be executed!") + return + + if Engine.get_version_info().hex < 0x40500: + prints("This GdUnit4 plugin version '%s' requires Godot version '4.5' or higher to run." % GdUnit4Version.current()) + return + GdUnitSettings.setup() + GdUnitEditorColorTheme.setup() + # Install the GdUnit Inspector + _gd_inspector = (load("res://addons/gdUnit4/src/ui/GdUnitInspector.tscn") as PackedScene).instantiate() + _add_context_menus() + add_control_to_dock(EditorPlugin.DOCK_SLOT_LEFT_UR, _gd_inspector) + # Install the GdUnit Console + _gd_console = (load("res://addons/gdUnit4/src/ui/GdUnitConsole.tscn") as PackedScene).instantiate() + var control: Control = add_control_to_bottom_panel(_gd_console, "gdUnitConsole") + @warning_ignore("unsafe_method_access") + await _gd_console.setup_update_notification(control) + if GdUnit4CSharpApiLoader.is_api_loaded(): + prints("GdUnit4Net version '%s' loaded." % GdUnit4CSharpApiLoader.version()) + else: + prints("No GdUnit4Net found.") + # Connect to be notified for script changes to be able to discover new tests + GdUnitTestDiscoverGuard.instance() + @warning_ignore("return_value_discarded") + resource_saved.connect(_on_resource_saved) + prints("Loading GdUnit4 Plugin success") + + +func _exit_tree() -> void: + if check_running_in_test_env(): + return + if is_instance_valid(_gd_inspector): + remove_control_from_docks(_gd_inspector) + _gd_inspector.free() + _remove_context_menus() + if is_instance_valid(_gd_console): + remove_control_from_bottom_panel(_gd_console) + _gd_console.free() + var gdUnitTools: GDScript = load("res://addons/gdUnit4/src/core/GdUnitTools.gd") + @warning_ignore("unsafe_method_access") + gdUnitTools.dispose_all(true) + prints("Unload GdUnit4 Plugin success") + + +func check_running_in_test_env() -> bool: + var args: PackedStringArray = OS.get_cmdline_args() + args.append_array(OS.get_cmdline_user_args()) + return DisplayServer.get_name() == "headless" or args.has("--selftest") or args.has("--add") or args.has("-a") or args.has("--quit-after") or args.has("--import") + + +func _add_context_menus() -> void: + _filesystem_context_menu = preload("res://addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd").new() + _editor_context_menu = preload("res://addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd").new() + _editor_code_context_menu = preload("res://addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd").new() + add_context_menu_plugin(EditorContextMenuPlugin.CONTEXT_SLOT_FILESYSTEM, _filesystem_context_menu) + add_context_menu_plugin(EditorContextMenuPlugin.CONTEXT_SLOT_SCRIPT_EDITOR, _editor_context_menu) + add_context_menu_plugin(EditorContextMenuPlugin.CONTEXT_SLOT_SCRIPT_EDITOR_CODE, _editor_code_context_menu) + + +func _remove_context_menus() -> void: + if is_instance_valid(_filesystem_context_menu): + remove_context_menu_plugin(_filesystem_context_menu) + if is_instance_valid(_editor_context_menu): + remove_context_menu_plugin(_editor_context_menu) + if is_instance_valid(_editor_code_context_menu): + remove_context_menu_plugin(_editor_code_context_menu) + + +func _on_resource_saved(resource: Resource) -> void: + if resource is Script: + await GdUnitTestDiscoverGuard.instance().discover(resource as Script) diff --git a/addons/gdUnit4/plugin.gd.uid b/addons/gdUnit4/plugin.gd.uid new file mode 100644 index 0000000..7a11ead --- /dev/null +++ b/addons/gdUnit4/plugin.gd.uid @@ -0,0 +1 @@ +uid://qmkf30a5bmap diff --git a/addons/gdUnit4/runtest.cmd b/addons/gdUnit4/runtest.cmd new file mode 100644 index 0000000..bc00a4e --- /dev/null +++ b/addons/gdUnit4/runtest.cmd @@ -0,0 +1,65 @@ +@echo off +setlocal enabledelayedexpansion + +:: Initialize variables +set "godot_binary=" +set "filtered_args=" + +:: Process all arguments +set "i=0" +:parse_args +if "%~1"=="" goto end_parse_args + +if "%~1"=="--godot_binary" ( + set "godot_binary=%~2" + shift + shift +) else ( + set "filtered_args=!filtered_args! %~1" + shift +) +goto parse_args +:end_parse_args + +:: If --godot_binary wasn't provided, fallback to environment variable +if "!godot_binary!"=="" ( + set "godot_binary=%GODOT_BIN%" +) + +:: Check if we have a godot_binary value from any source +if "!godot_binary!"=="" ( + echo Godot binary path is not specified. + echo Please either: + echo - Set the environment variable: set GODOT_BIN=C:\path\to\godot.exe + echo - Or use the --godot_binary argument: --godot_binary C:\path\to\godot.exe + exit /b 1 +) + +:: Check if the Godot binary exists +if not exist "!godot_binary!" ( + echo Error: The specified Godot binary '!godot_binary!' does not exist. + exit /b 1 +) + +:: Get Godot version and check if it's a mono build +for /f "tokens=*" %%i in ('"!godot_binary!" --version') do set GODOT_VERSION=%%i +echo !GODOT_VERSION! | findstr /I "mono" >nul +if !errorlevel! equ 0 ( + echo Godot .NET detected + echo Compiling c# classes ... Please Wait + dotnet build --debug + echo done !errorlevel! +) + +:: Run the tests with the filtered arguments. +:: --remote-debug tcp://127.0.0.1:0 prevents Godot from activating its local interactive +:: CLI debugger, which would cause an endless 'debug>' loop on script parse errors. +:: Port 0 is used intentionally as it is never bound, so the connection is always refused. +"!godot_binary!" --path . -s -d --remote-debug tcp://127.0.0.1:0 res://addons/gdUnit4/bin/GdUnitCmdTool.gd !filtered_args! +set exit_code=%ERRORLEVEL% +echo Run tests ends with %exit_code% + +:: Run the copy log command +"!godot_binary!" --headless --path . --quiet -s res://addons/gdUnit4/bin/GdUnitCopyLog.gd !filtered_args! > nul +set exit_code2=%ERRORLEVEL% +exit /b %exit_code% diff --git a/addons/gdUnit4/runtest.sh b/addons/gdUnit4/runtest.sh new file mode 100755 index 0000000..4de7d7a --- /dev/null +++ b/addons/gdUnit4/runtest.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# Check for command-line argument +godot_binary="" +filtered_args="" + +# Process all arguments with a more compatible approach +while [ $# -gt 0 ]; do + if [ "$1" = "--godot_binary" ] && [ $# -gt 1 ]; then + # Get the next argument as the value + godot_binary="$2" + shift 2 + else + # Keep non-godot_binary arguments for passing to Godot + filtered_args="$filtered_args $1" + shift + fi +done + +# If --godot_binary wasn't provided, fallback to environment variable +if [ -z "$godot_binary" ]; then + godot_binary="$GODOT_BIN" +fi + +# Check if we have a godot_binary value from any source +if [ -z "$godot_binary" ]; then + echo "Godot binary path is not specified." + echo "Please either:" + echo " - Set the environment variable: export GODOT_BIN=/path/to/godot" + echo " - Or use the --godot_binary argument: --godot_binary /path/to/godot" + exit 1 +fi + +# Check if the Godot binary exists and is executable +if [ ! -f "$godot_binary" ]; then + echo "Error: The specified Godot binary '$godot_binary' does not exist." + exit 1 +fi + +if [ ! -x "$godot_binary" ]; then + echo "Error: The specified Godot binary '$godot_binary' is not executable." + exit 1 +fi + +# Get Godot version and check if it's a .NET build +GODOT_VERSION=$("$godot_binary" --version) +if echo "$GODOT_VERSION" | grep -i "mono" > /dev/null; then + echo "Godot .NET detected" + echo "Compiling c# classes ... Please Wait" + dotnet build --debug + echo "done $?" +fi + +# Run the tests with the filtered arguments. +# --remote-debug tcp://127.0.0.1:0 prevents Godot from activating its local interactive +# CLI debugger, which would cause an endless 'debug>' loop on script parse errors. +# Port 0 is used intentionally as it is never bound, so the connection is always refused. +"$godot_binary" --path . -s -d --remote-debug tcp://127.0.0.1:0 res://addons/gdUnit4/bin/GdUnitCmdTool.gd $filtered_args +exit_code=$? +echo "Run tests ends with $exit_code" + +# Run the copy log command +"$godot_binary" --headless --path . --quiet -s res://addons/gdUnit4/bin/GdUnitCopyLog.gd $filtered_args > /dev/null +exit_code2=$? +exit $exit_code diff --git a/addons/gdUnit4/src/Comparator.gd b/addons/gdUnit4/src/Comparator.gd new file mode 100644 index 0000000..096088a --- /dev/null +++ b/addons/gdUnit4/src/Comparator.gd @@ -0,0 +1,12 @@ +class_name Comparator +extends Resource + +enum { + EQUAL, + LESS_THAN, + LESS_EQUAL, + GREATER_THAN, + GREATER_EQUAL, + BETWEEN_EQUAL, + NOT_BETWEEN_EQUAL, +} diff --git a/addons/gdUnit4/src/Comparator.gd.uid b/addons/gdUnit4/src/Comparator.gd.uid new file mode 100644 index 0000000..50b5fdf --- /dev/null +++ b/addons/gdUnit4/src/Comparator.gd.uid @@ -0,0 +1 @@ +uid://dapcyf8uxkfwt diff --git a/addons/gdUnit4/src/Fuzzers.gd b/addons/gdUnit4/src/Fuzzers.gd new file mode 100644 index 0000000..c8689df --- /dev/null +++ b/addons/gdUnit4/src/Fuzzers.gd @@ -0,0 +1,80 @@ +## Factory class providing convenient static methods to create various fuzzer instances.[br] +## +## Fuzzers is a utility class that simplifies the creation of different fuzzer types +## for testing purposes. It provides static factory methods that create pre-configured +## fuzzers with sensible defaults, making it easier to set up fuzz testing in your +## test suites without manually instantiating each fuzzer type.[br] +## +## This class acts as a central access point for all fuzzer types, improving code +## readability and reducing boilerplate in test cases.[br] +## +## @tutorial(Fuzzing Testing): https://en.wikipedia.org/wiki/Fuzzing +class_name Fuzzers +extends Resource + + +## Generates random strings with length between [param min_length] and +## [param max_length] (inclusive), using characters from [param charset]. +## See [StringFuzzer] for detailed documentation and examples. +static func rand_str(min_length: int, max_length: int, charset := StringFuzzer.DEFAULT_CHARSET) -> StringFuzzer: + return StringFuzzer.new(min_length, max_length, charset) + + +## Creates a [BoolFuzzer] for generating random boolean values.[br] +## +## See [BoolFuzzer] for detailed documentation and examples. +static func boolean() -> BoolFuzzer: + return BoolFuzzer.new() + + +## Creates an [IntFuzzer] for generating random integers within a range.[br] +## +## Generates random integers between [param from] and [param to] (inclusive) +## using [constant IntFuzzer.NORMAL] mode. +## See [IntFuzzer] for detailed documentation and examples. +static func rangei(from: int, to: int) -> IntFuzzer: + return IntFuzzer.new(from, to) + + +## Creates a [FloatFuzzer] for generating random floats within a range.[br] +## +## Generates random float values between [param from] and [param to] (inclusive). +## See [FloatFuzzer] for detailed documentation and examples. +static func rangef(from: float, to: float) -> FloatFuzzer: + return FloatFuzzer.new(from, to) + + +## Creates a [Vector2Fuzzer] for generating random 2D vectors within a range.[br] +## +## Generates random Vector2 values where each component is bounded by +## [param from] and [param to] (inclusive). +## See [Vector2Fuzzer] for detailed documentation and examples. +static func rangev2(from: Vector2, to: Vector2) -> Vector2Fuzzer: + return Vector2Fuzzer.new(from, to) + + +## Creates a [Vector3Fuzzer] for generating random 3D vectors within a range.[br] +## +## Generates random Vector3 values where each component is bounded by +## [param from] and [param to] (inclusive). +## See [Vector3Fuzzer] for detailed documentation and examples. +static func rangev3(from: Vector3, to: Vector3) -> Vector3Fuzzer: + return Vector3Fuzzer.new(from, to) + + +## Creates an [IntFuzzer] that generates only even integers.[br] +## +## Generates random even integers between [param from] and [param to] (inclusive) +## using [constant IntFuzzer.EVEN] mode. +## See [IntFuzzer] for detailed documentation about even number generation. +static func eveni(from: int, to: int) -> IntFuzzer: + return IntFuzzer.new(from, to, IntFuzzer.EVEN) + + +## Creates an [IntFuzzer] that generates only odd integers.[br] +## +## Generates random odd integers between [param from] and [param to] (inclusive) +## using [constant IntFuzzer.ODD] mode. +## See [IntFuzzer] for detailed documentation about odd number generation. +static func oddi(from: int, to: int) -> IntFuzzer: + return IntFuzzer.new(from, to, IntFuzzer.ODD) diff --git a/addons/gdUnit4/src/Fuzzers.gd.uid b/addons/gdUnit4/src/Fuzzers.gd.uid new file mode 100644 index 0000000..6cc0ac3 --- /dev/null +++ b/addons/gdUnit4/src/Fuzzers.gd.uid @@ -0,0 +1 @@ +uid://b1ctmh8qmi5d0 diff --git a/addons/gdUnit4/src/GdUnitArrayAssert.gd b/addons/gdUnit4/src/GdUnitArrayAssert.gd new file mode 100644 index 0000000..eeb7ca6 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitArrayAssert.gd @@ -0,0 +1,122 @@ +## An Assertion Tool to verify array values +@abstract class_name GdUnitArrayAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitArrayAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitArrayAssert + + +## Verifies that the current Array is equal to the given one. +@abstract func is_equal(...expected: Array) -> GdUnitArrayAssert + + +## Verifies that the current Array is equal to the given one, ignoring case considerations. +@abstract func is_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert + + +## Verifies that the current Array is not equal to the given one. +@abstract func is_not_equal(...expected: Array) -> GdUnitArrayAssert + + +## Verifies that the current Array is not equal to the given one, ignoring case considerations. +@abstract func is_not_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitArrayAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitArrayAssert + + +## Verifies that the current Array is empty, it has a size of 0. +@abstract func is_empty() -> GdUnitArrayAssert + + +## Verifies that the current Array is not empty, it has a size of minimum 1. +@abstract func is_not_empty() -> GdUnitArrayAssert + + +## Verifies that the current Array is the same. [br] +## Compares the current by object reference equals +@abstract func is_same(expected: Variant) -> GdUnitArrayAssert + + +## Verifies that the current Array is NOT the same. [br] +## Compares the current by object reference equals +@abstract func is_not_same(expected: Variant) -> GdUnitArrayAssert + + +## Verifies that the current Array has a size of given value. +@abstract func has_size(expectd: int) -> GdUnitArrayAssert + + +## Verifies that the current Array contains the given values, in any order.[br] +## The values are compared by deep parameter comparision, for object reference compare you have to use [method contains_same] +@abstract func contains(...expected: Array) -> GdUnitArrayAssert + + +## Verifies that the current Array contains exactly only the given values and nothing else, in same order.[br] +## The values are compared by deep parameter comparision, for object reference compare you have to use [method contains_same_exactly] +@abstract func contains_exactly(...expected: Array) -> GdUnitArrayAssert + + +## Verifies that the current Array contains exactly only the given values and nothing else, in any order.[br] +## The values are compared by deep parameter comparision, for object reference compare you have to use [method contains_same_exactly_in_any_order] +@abstract func contains_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert + + +## Verifies that the current Array contains the given values, in any order.[br] +## The values are compared by object reference, for deep parameter comparision use [method contains] +@abstract func contains_same(...expected: Array) -> GdUnitArrayAssert + + +## Verifies that the current Array contains exactly only the given values and nothing else, in same order.[br] +## The values are compared by object reference, for deep parameter comparision use [method contains_exactly] +@abstract func contains_same_exactly(...expected: Array) -> GdUnitArrayAssert + + +## Verifies that the current Array contains exactly only the given values and nothing else, in any order.[br] +## The values are compared by object reference, for deep parameter comparision use [method contains_exactly_in_any_order] +@abstract func contains_same_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert + + +## Verifies that the current Array do NOT contains the given values, in any order.[br] +## The values are compared by deep parameter comparision, for object reference compare you have to use [method not_contains_same] +## [b]Example:[/b] +## [codeblock] +## # will succeed +## assert_array([1, 2, 3, 4, 5]).not_contains(6) +## # will fail +## assert_array([1, 2, 3, 4, 5]).not_contains(2, 6) +## [/codeblock] +@abstract func not_contains(...expected: Array) -> GdUnitArrayAssert + + +## Verifies that the current Array do NOT contains the given values, in any order.[br] +## The values are compared by object reference, for deep parameter comparision use [method not_contains] +## [b]Example:[/b] +## [codeblock] +## # will succeed +## assert_array([1, 2, 3, 4, 5]).not_contains(6) +## # will fail +## assert_array([1, 2, 3, 4, 5]).not_contains(2, 6) +## [/codeblock] +@abstract func not_contains_same(...expected: Array) -> GdUnitArrayAssert + + +## Extracts all values by given function name and optional arguments into a new ArrayAssert. +## If the elements not accessible by `func_name` the value is converted to `"n.a"`, expecting null values +@abstract func extract(func_name: String, ...func_args: Array) -> GdUnitArrayAssert + + +## Extracts all values by given extractor's into a new ArrayAssert. +## If the elements not extractable than the value is converted to `"n.a"`, expecting null values +## -- The argument type is Array[GdUnitValueExtractor] +@abstract func extractv(...extractors: Array) -> GdUnitArrayAssert diff --git a/addons/gdUnit4/src/GdUnitArrayAssert.gd.uid b/addons/gdUnit4/src/GdUnitArrayAssert.gd.uid new file mode 100644 index 0000000..f220c1b --- /dev/null +++ b/addons/gdUnit4/src/GdUnitArrayAssert.gd.uid @@ -0,0 +1 @@ +uid://cdmo2fwnaxtpe diff --git a/addons/gdUnit4/src/GdUnitAssert.gd b/addons/gdUnit4/src/GdUnitAssert.gd new file mode 100644 index 0000000..41382d9 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitAssert.gd @@ -0,0 +1,47 @@ +## Base interface of all GdUnit asserts +@abstract class_name GdUnitAssert +extends RefCounted + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitAssert + + +## Verifies that the current value is equal to expected one. +@abstract func is_equal(expected: Variant) -> GdUnitAssert + + +## Verifies that the current value is not equal to expected one. +@abstract func is_not_equal(expected: Variant) -> GdUnitAssert + + +## Overrides the default failure message by given custom message.[br] +## This function allows you to replace the automatically generated failure message with a more specific +## or user-friendly message that better describes the test failure context.[br] +## Usage: +## [codeblock] +## # Override with custom context-specific message +## func test_player_inventory(): +## assert_that(player.get_item_count("sword"))\ +## .override_failure_message("Player should have exactly one sword")\ +## .is_equal(1) +## [/codeblock] +@abstract func override_failure_message(message: String) -> GdUnitAssert + + +## Appends a custom message to the failure message.[br] +## This can be used to add additional information to the generated failure message +## while keeping the original assertion details for better debugging context.[br] +## Usage: +## [codeblock] +## # Add context to existing failure message +## func test_player_health(): +## assert_that(player.health)\ +## .append_failure_message("Player was damaged by: %s" % last_damage_source)\ +## .is_greater(0) +## [/codeblock] +@abstract func append_failure_message(message: String) -> GdUnitAssert diff --git a/addons/gdUnit4/src/GdUnitAssert.gd.uid b/addons/gdUnit4/src/GdUnitAssert.gd.uid new file mode 100644 index 0000000..7d0dd96 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitAssert.gd.uid @@ -0,0 +1 @@ +uid://doph0d61vwg81 diff --git a/addons/gdUnit4/src/GdUnitAwaiter.gd b/addons/gdUnit4/src/GdUnitAwaiter.gd new file mode 100644 index 0000000..06e3171 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitAwaiter.gd @@ -0,0 +1,73 @@ +class_name GdUnitAwaiter +extends RefCounted + + +# Waits for a specified signal in an interval of 50ms sent from the , and terminates with an error after the specified timeout has elapsed. +# source: the object from which the signal is emitted +# signal_name: signal name +# args: the expected signal arguments as an array +# timeout: the timeout in ms, default is set to 2000ms +func await_signal_on(source :Object, signal_name :String, args :Array = [], timeout_millis :int = 2000) -> Variant: + # fail fast if the given source instance invalid + var assert_that := GdUnitAssertImpl.new(signal_name) + var stack_trace := GdUnitStackTrace.new() + var line_number := stack_trace.get_line_number() + if not is_instance_valid(source): + @warning_ignore("return_value_discarded") + assert_that.report_error(GdAssertMessages.error_await_signal_on_invalid_instance(source, signal_name, args), line_number) + return await (Engine.get_main_loop() as SceneTree).process_frame + # fail fast if the given source instance invalid + if not is_instance_valid(source): + @warning_ignore("return_value_discarded") + assert_that.report_error(GdAssertMessages.error_await_signal_on_invalid_instance(source, signal_name, args), line_number) + return await await_idle_frame() + var awaiter := GdUnitSignalAwaiter.new(timeout_millis) + var value :Variant = await awaiter.on_signal(source, signal_name, args) + if awaiter.is_interrupted(): + var failure := "await_signal_on(%s, %s) timed out after %sms" % [signal_name, args, timeout_millis] + @warning_ignore("return_value_discarded") + assert_that.report_error(failure, line_number) + return value + + +# Waits for a specified signal sent from the between idle frames and aborts with an error after the specified timeout has elapsed +# source: the object from which the signal is emitted +# signal_name: signal name +# args: the expected signal arguments as an array +# timeout: the timeout in ms, default is set to 2000ms +func await_signal_idle_frames(source :Object, signal_name :String, args :Array = [], timeout_millis :int = 2000) -> Variant: + var line_number := GdUnitStackTrace.new().get_line_number() + # fail fast if the given source instance invalid + if not is_instance_valid(source): + @warning_ignore("return_value_discarded") + GdUnitAssertImpl.new(signal_name)\ + .report_error(GdAssertMessages.error_await_signal_on_invalid_instance(source, signal_name, args), line_number) + return await await_idle_frame() + var awaiter := GdUnitSignalAwaiter.new(timeout_millis, true) + var value :Variant = await awaiter.on_signal(source, signal_name, args) + if awaiter.is_interrupted(): + var failure := "await_signal_idle_frames(%s, %s) timed out after %sms" % [signal_name, args, timeout_millis] + @warning_ignore("return_value_discarded") + GdUnitAssertImpl.new(signal_name).report_error(failure, line_number) + return value + + +# Waits for for a given amount of milliseconds +# example: +# # waits for 100ms +# await GdUnitAwaiter.await_millis(myNode, 100).completed +# use this waiter and not `await get_tree().create_timer().timeout to prevent errors when a test case is timed out +func await_millis(milliSec :int) -> void: + var timer :Timer = Timer.new() + timer.set_name("gdunit_await_millis_timer_%d" % timer.get_instance_id()) + (Engine.get_main_loop() as SceneTree).root.add_child(timer) + timer.add_to_group("GdUnitTimers") + timer.set_one_shot(true) + timer.start(milliSec / 1000.0) + await timer.timeout + timer.queue_free() + + +# Waits until the next idle frame +func await_idle_frame() -> void: + await (Engine.get_main_loop() as SceneTree).process_frame diff --git a/addons/gdUnit4/src/GdUnitAwaiter.gd.uid b/addons/gdUnit4/src/GdUnitAwaiter.gd.uid new file mode 100644 index 0000000..c4f587b --- /dev/null +++ b/addons/gdUnit4/src/GdUnitAwaiter.gd.uid @@ -0,0 +1 @@ +uid://do0d4vult83qq diff --git a/addons/gdUnit4/src/GdUnitBoolAssert.gd b/addons/gdUnit4/src/GdUnitBoolAssert.gd new file mode 100644 index 0000000..714f8fc --- /dev/null +++ b/addons/gdUnit4/src/GdUnitBoolAssert.gd @@ -0,0 +1,35 @@ +## An Assertion Tool to verify boolean values +@abstract class_name GdUnitBoolAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitBoolAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitBoolAssert + + +## Verifies that the current value is equal to the given one. +@abstract func is_equal(expected: Variant) -> GdUnitBoolAssert + + +## Verifies that the current value is not equal to the given one. +@abstract func is_not_equal(expected: Variant) -> GdUnitBoolAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitBoolAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitBoolAssert + + +## Verifies that the current value is true. +@abstract func is_true() -> GdUnitBoolAssert + + +## Verifies that the current value is false. +@abstract func is_false() -> GdUnitBoolAssert diff --git a/addons/gdUnit4/src/GdUnitBoolAssert.gd.uid b/addons/gdUnit4/src/GdUnitBoolAssert.gd.uid new file mode 100644 index 0000000..77b4b71 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitBoolAssert.gd.uid @@ -0,0 +1 @@ +uid://yucqgte8f3lr diff --git a/addons/gdUnit4/src/GdUnitConstants.gd b/addons/gdUnit4/src/GdUnitConstants.gd new file mode 100644 index 0000000..e43c75a --- /dev/null +++ b/addons/gdUnit4/src/GdUnitConstants.gd @@ -0,0 +1,10 @@ +class_name GdUnitConstants +extends RefCounted + +const NO_ARG :Variant = "<--null-->" + +const EXPECT_ASSERT_REPORT_FAILURES := "expect_assert_report_failures" + +## The maximum number of report history files to store +const DEFAULT_REPORT_HISTORY_COUNT = 20 +const REPORT_DIR_PREFIX = "report_" diff --git a/addons/gdUnit4/src/GdUnitConstants.gd.uid b/addons/gdUnit4/src/GdUnitConstants.gd.uid new file mode 100644 index 0000000..bea8afb --- /dev/null +++ b/addons/gdUnit4/src/GdUnitConstants.gd.uid @@ -0,0 +1 @@ +uid://b8wk88u0ki4n7 diff --git a/addons/gdUnit4/src/GdUnitDictionaryAssert.gd b/addons/gdUnit4/src/GdUnitDictionaryAssert.gd new file mode 100644 index 0000000..45cc62a --- /dev/null +++ b/addons/gdUnit4/src/GdUnitDictionaryAssert.gd @@ -0,0 +1,79 @@ +## An Assertion Tool to verify dictionary +@abstract class_name GdUnitDictionaryAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitDictionaryAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary is equal to the given one, ignoring order. +@abstract func is_equal(expected: Variant) -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary is not equal to the given one, ignoring order. +@abstract func is_not_equal(expected: Variant) -> GdUnitDictionaryAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitDictionaryAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary is empty, it has a size of 0. +@abstract func is_empty() -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary is not empty, it has a size of minimum 1. +@abstract func is_not_empty() -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary is the same. [br] +## Compares the current by object reference equals +@abstract func is_same(expected: Variant) -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary is NOT the same. [br] +## Compares the current by object reference equals +@abstract func is_not_same(expected: Variant) -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary has a size of given value. +@abstract func has_size(expected: int) -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary contains the given key(s).[br] +## The keys are compared by deep parameter comparision, for object reference compare you have to use [method contains_same_keys] +@abstract func contains_keys(...expected: Array) -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary contains the given key and value.[br] +## The key and value are compared by deep parameter comparision, for object reference compare you have to use [method contains_same_key_value] +@abstract func contains_key_value(key: Variant, value: Variant) -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary not contains the given key(s).[br] +## The keys are compared by deep parameter comparision, for object reference compare you have to use [method not_contains_same_keys] +@abstract func not_contains_keys(...expected: Array) -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary contains the given key(s).[br] +## The keys are compared by object reference, for deep parameter comparision use [method contains_keys] +@abstract func contains_same_keys(expected: Array) -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary contains the given key and value.[br] +## The key and value are compared by object reference, for deep parameter comparision use [method contains_key_value] +@abstract func contains_same_key_value(key: Variant, value: Variant) -> GdUnitDictionaryAssert + + +## Verifies that the current dictionary not contains the given key(s). +## The keys are compared by object reference, for deep parameter comparision use [method not_contains_keys] +@abstract func not_contains_same_keys(...expected: Array) -> GdUnitDictionaryAssert diff --git a/addons/gdUnit4/src/GdUnitDictionaryAssert.gd.uid b/addons/gdUnit4/src/GdUnitDictionaryAssert.gd.uid new file mode 100644 index 0000000..3ed09bf --- /dev/null +++ b/addons/gdUnit4/src/GdUnitDictionaryAssert.gd.uid @@ -0,0 +1 @@ +uid://c52l1pf0xf6kd diff --git a/addons/gdUnit4/src/GdUnitFailureAssert.gd b/addons/gdUnit4/src/GdUnitFailureAssert.gd new file mode 100644 index 0000000..de34aa8 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitFailureAssert.gd @@ -0,0 +1,56 @@ +## An assertion tool to verify GDUnit asserts. +## This assert is for internal use only, to verify that failed asserts work as expected. +@abstract class_name GdUnitFailureAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitFailureAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitFailureAssert + + +## Verifies that the current value is equal to the given one. +@abstract func is_equal(expected: Variant) -> GdUnitFailureAssert + + +## Verifies that the current value is not equal to expected one. +@abstract func is_not_equal(expected: Variant) -> GdUnitFailureAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitFailureAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitFailureAssert + + +## Verifies if the executed assert was successful +@abstract func is_success() -> GdUnitFailureAssert + + +## Verifies if the executed assert has failed +@abstract func is_failed() -> GdUnitFailureAssert + + +## Verifies the failure line is equal to expected one. +@abstract func has_line(expected: int) -> GdUnitFailureAssert + + +## Verifies the failure stack trace is equal to expected one. +@abstract func has_stack_trace(stack_trace: Array[GdUnitStackTraceElement]) -> GdUnitFailureAssert + + +## Verifies the failure message is equal to expected one. +@abstract func has_message(expected: String) -> GdUnitFailureAssert + + +## Verifies that the failure message starts with the expected message. +@abstract func starts_with_message(expected: String) -> GdUnitFailureAssert + + +## Verifies that the failure message contains the expected message. +@abstract func contains_message(expected: String) -> GdUnitFailureAssert diff --git a/addons/gdUnit4/src/GdUnitFailureAssert.gd.uid b/addons/gdUnit4/src/GdUnitFailureAssert.gd.uid new file mode 100644 index 0000000..6f4f7cc --- /dev/null +++ b/addons/gdUnit4/src/GdUnitFailureAssert.gd.uid @@ -0,0 +1 @@ +uid://b7u30qhxenurx diff --git a/addons/gdUnit4/src/GdUnitFileAssert.gd b/addons/gdUnit4/src/GdUnitFileAssert.gd new file mode 100644 index 0000000..771da90 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitFileAssert.gd @@ -0,0 +1,38 @@ +@abstract class_name GdUnitFileAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitFileAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitFileAssert + + +## Verifies that the current value is equal to the given one. +@abstract func is_equal(expected: Variant) -> GdUnitFileAssert + + +## Verifies that the current value is not equal to expected one. +@abstract func is_not_equal(expected: Variant) -> GdUnitFileAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitFileAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitFileAssert + + +@abstract func is_file() -> GdUnitFileAssert + + +@abstract func exists() -> GdUnitFileAssert + + +@abstract func is_script() -> GdUnitFileAssert + + +@abstract func contains_exactly(expected_rows :Array) -> GdUnitFileAssert diff --git a/addons/gdUnit4/src/GdUnitFileAssert.gd.uid b/addons/gdUnit4/src/GdUnitFileAssert.gd.uid new file mode 100644 index 0000000..59169b0 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitFileAssert.gd.uid @@ -0,0 +1 @@ +uid://oiy1n0qmt45d diff --git a/addons/gdUnit4/src/GdUnitFloatAssert.gd b/addons/gdUnit4/src/GdUnitFloatAssert.gd new file mode 100644 index 0000000..2695ab0 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitFloatAssert.gd @@ -0,0 +1,75 @@ +## An Assertion Tool to verify float values +@abstract class_name GdUnitFloatAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitFloatAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitFloatAssert + + +## Verifies that the current value is equal to the given one. +@abstract func is_equal(expected: Variant) -> GdUnitFloatAssert + + +## Verifies that the current value is not equal to expected one. +@abstract func is_not_equal(expected: Variant) -> GdUnitFloatAssert + + +## Verifies that the current and expected value are approximately equal. +@abstract func is_equal_approx(expected: float, approx: float) -> GdUnitFloatAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitFloatAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitFloatAssert + + +## Verifies that the current value is less than the given one. +@abstract func is_less(expected: float) -> GdUnitFloatAssert + + +## Verifies that the current value is less than or equal the given one. +@abstract func is_less_equal(expected: float) -> GdUnitFloatAssert + + +## Verifies that the current value is greater than the given one. +@abstract func is_greater(expected: float) -> GdUnitFloatAssert + + +## Verifies that the current value is greater than or equal the given one. +@abstract func is_greater_equal(expected: float) -> GdUnitFloatAssert + + +## Verifies that the current value is negative. +@abstract func is_negative() -> GdUnitFloatAssert + + +## Verifies that the current value is not negative. +@abstract func is_not_negative() -> GdUnitFloatAssert + + +## Verifies that the current value is equal to zero. +@abstract func is_zero() -> GdUnitFloatAssert + + +## Verifies that the current value is not equal to zero. +@abstract func is_not_zero() -> GdUnitFloatAssert + + +## Verifies that the current value is in the given set of values. +@abstract func is_in(expected: Array) -> GdUnitFloatAssert + + +## Verifies that the current value is not in the given set of values. +@abstract func is_not_in(expected: Array) -> GdUnitFloatAssert + + +## Verifies that the current value is between the given boundaries (inclusive). +@abstract func is_between(from: float, to: float) -> GdUnitFloatAssert diff --git a/addons/gdUnit4/src/GdUnitFloatAssert.gd.uid b/addons/gdUnit4/src/GdUnitFloatAssert.gd.uid new file mode 100644 index 0000000..02678fc --- /dev/null +++ b/addons/gdUnit4/src/GdUnitFloatAssert.gd.uid @@ -0,0 +1 @@ +uid://c82pxwpofcaa3 diff --git a/addons/gdUnit4/src/GdUnitFuncAssert.gd b/addons/gdUnit4/src/GdUnitFuncAssert.gd new file mode 100644 index 0000000..e8a49c5 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitFuncAssert.gd @@ -0,0 +1,42 @@ +## An Assertion Tool to verify function callback values +@abstract class_name GdUnitFuncAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitFuncAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitFuncAssert + + +## Verifies that the current value is equal to the given one. +@abstract func is_equal(expected: Variant) -> GdUnitFuncAssert + + +## Verifies that the current value is not equal to expected one. +@abstract func is_not_equal(expected: Variant) -> GdUnitFuncAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitFuncAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitFuncAssert + + +## Verifies that the current value is true. +@abstract func is_true() -> GdUnitFuncAssert + + +## Verifies that the current value is false. +@abstract func is_false() -> GdUnitFuncAssert + + +## Sets the timeout in ms to wait the function returnd the expected value, if the time over a failure is emitted.[br] +## e.g.[br] +## do wait until 5s the function `is_state` is returns 10 [br] +## [code]assert_func(instance, "is_state").wait_until(5000).is_equal(10)[/code] +@abstract func wait_until(timeout: int) -> GdUnitFuncAssert diff --git a/addons/gdUnit4/src/GdUnitFuncAssert.gd.uid b/addons/gdUnit4/src/GdUnitFuncAssert.gd.uid new file mode 100644 index 0000000..a700530 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitFuncAssert.gd.uid @@ -0,0 +1 @@ +uid://bf4ugqv5d4oy1 diff --git a/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd b/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd new file mode 100644 index 0000000..01711f9 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd @@ -0,0 +1,59 @@ +## An assertion tool to verify for Godot runtime errors like assert() and push notifications like push_error(). +@abstract class_name GdUnitGodotErrorAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitGodotErrorAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitGodotErrorAssert + + +## Verifies that the current value is equal to the given one. +@abstract func is_equal(expected: Variant) -> GdUnitGodotErrorAssert + + +## Verifies that the current value is not equal to expected one. +@abstract func is_not_equal(expected: Variant) -> GdUnitGodotErrorAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitGodotErrorAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitGodotErrorAssert + + +## Verifies if the executed code runs without any runtime errors +## Usage: +## [codeblock] +## await assert_error().is_success() +## [/codeblock] +@abstract func is_success() -> GdUnitGodotErrorAssert + + +## Verifies if the executed code runs into a runtime error +## Usage: +## [codeblock] +## await assert_error().is_runtime_error() +## [/codeblock] +@abstract func is_runtime_error(expected_error: Variant) -> GdUnitGodotErrorAssert + + +## Verifies if the executed code has a push_warning() used +## Usage: +## [codeblock] +## await assert_error().is_push_warning() +## [/codeblock] +@abstract func is_push_warning(expected_warning: Variant) -> GdUnitGodotErrorAssert + + +## Verifies if the executed code has a push_error() used +## Usage: +## [codeblock] +## await assert_error().is_push_error() +## [/codeblock] +@abstract func is_push_error(expected_error: Variant) -> GdUnitGodotErrorAssert diff --git a/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd.uid b/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd.uid new file mode 100644 index 0000000..e401b93 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd.uid @@ -0,0 +1 @@ +uid://dkub4b1kkhi86 diff --git a/addons/gdUnit4/src/GdUnitIntAssert.gd b/addons/gdUnit4/src/GdUnitIntAssert.gd new file mode 100644 index 0000000..05eb922 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitIntAssert.gd @@ -0,0 +1,79 @@ +## An Assertion Tool to verify integer values +@abstract class_name GdUnitIntAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitIntAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitIntAssert + + +## Verifies that the current value is equal to the given one. +@abstract func is_equal(expected: Variant) -> GdUnitIntAssert + + +## Verifies that the current value is not equal to expected one. +@abstract func is_not_equal(expected: Variant) -> GdUnitIntAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitIntAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitIntAssert + + +## Verifies that the current value is less than the given one. +@abstract func is_less(expected: int) -> GdUnitIntAssert + + +## Verifies that the current value is less than or equal the given one. +@abstract func is_less_equal(expected: int) -> GdUnitIntAssert + + +## Verifies that the current value is greater than the given one. +@abstract func is_greater(expected: int) -> GdUnitIntAssert + + +## Verifies that the current value is greater than or equal the given one. +@abstract func is_greater_equal(expected: int) -> GdUnitIntAssert + + +## Verifies that the current value is even. +@abstract func is_even() -> GdUnitIntAssert + + +## Verifies that the current value is odd. +@abstract func is_odd() -> GdUnitIntAssert + + +## Verifies that the current value is negative. +@abstract func is_negative() -> GdUnitIntAssert + + +## Verifies that the current value is not negative. +@abstract func is_not_negative() -> GdUnitIntAssert + + +## Verifies that the current value is equal to zero. +@abstract func is_zero() -> GdUnitIntAssert + + +## Verifies that the current value is not equal to zero. +@abstract func is_not_zero() -> GdUnitIntAssert + + +## Verifies that the current value is in the given set of values. +@abstract func is_in(expected: Array) -> GdUnitIntAssert + + +## Verifies that the current value is not in the given set of values. +@abstract func is_not_in(expected: Array) -> GdUnitIntAssert + + +## Verifies that the current value is between the given boundaries (inclusive). +@abstract func is_between(from: int, to: int) -> GdUnitIntAssert diff --git a/addons/gdUnit4/src/GdUnitIntAssert.gd.uid b/addons/gdUnit4/src/GdUnitIntAssert.gd.uid new file mode 100644 index 0000000..c2f56a8 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitIntAssert.gd.uid @@ -0,0 +1 @@ +uid://cb8iji5xfd6mw diff --git a/addons/gdUnit4/src/GdUnitObjectAssert.gd b/addons/gdUnit4/src/GdUnitObjectAssert.gd new file mode 100644 index 0000000..c8bb1a3 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitObjectAssert.gd @@ -0,0 +1,61 @@ +## An Assertion Tool to verify Object values +@abstract class_name GdUnitObjectAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitObjectAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitObjectAssert + + +## Verifies that the current value is equal to the given one. +@abstract func is_equal(expected: Variant) -> GdUnitObjectAssert + + +## Verifies that the current value is not equal to expected one. +@abstract func is_not_equal(expected: Variant) -> GdUnitObjectAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitObjectAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitObjectAssert + + +## Verifies that the current object is the same as the given one. +@abstract func is_same(expected: Variant) -> GdUnitObjectAssert + + +## Verifies that the current object is not the same as the given one. +@abstract func is_not_same(expected: Variant) -> GdUnitObjectAssert + + +## Verifies that the current object is an instance of the given type. +@abstract func is_instanceof(type: Variant) -> GdUnitObjectAssert + + +## Verifies the current object is a valid instance (e.g. has not been freed from memory).[br] +## [codeblock] +## var node := Node.new() +## assert_object(node).is_valid() # passes — node is alive +## node.free() +## assert_object(node).is_valid() # fails — node has been freed +## [/codeblock] +@abstract func is_valid() -> GdUnitObjectAssert + + +## Verifies that the current object is not an instance of the given type. +@abstract func is_not_instanceof(type: Variant) -> GdUnitObjectAssert + + +## Checks whether the current object inherits from the specified type. +@abstract func is_inheriting(type: Variant) -> GdUnitObjectAssert + + +## Checks whether the current object does NOT inherit from the specified type. +@abstract func is_not_inheriting(type: Variant) -> GdUnitObjectAssert diff --git a/addons/gdUnit4/src/GdUnitObjectAssert.gd.uid b/addons/gdUnit4/src/GdUnitObjectAssert.gd.uid new file mode 100644 index 0000000..6dcbe23 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitObjectAssert.gd.uid @@ -0,0 +1 @@ +uid://dvu6dibmft8cm diff --git a/addons/gdUnit4/src/GdUnitResultAssert.gd b/addons/gdUnit4/src/GdUnitResultAssert.gd new file mode 100644 index 0000000..01eb880 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitResultAssert.gd @@ -0,0 +1,51 @@ +## An Assertion Tool to verify Results +@abstract class_name GdUnitResultAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitResultAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitResultAssert + + +## Verifies that the current value is equal to the given one. +@abstract func is_equal(expected: Variant) -> GdUnitResultAssert + + +## Verifies that the current value is not equal to expected one. +@abstract func is_not_equal(expected: Variant) -> GdUnitResultAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitResultAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitResultAssert + + +## Verifies that the result is ends up with empty +@abstract func is_empty() -> GdUnitResultAssert + + +## Verifies that the result is ends up with success +@abstract func is_success() -> GdUnitResultAssert + + +## Verifies that the result is ends up with warning +@abstract func is_warning() -> GdUnitResultAssert + + +## Verifies that the result is ends up with error +@abstract func is_error() -> GdUnitResultAssert + + +## Verifies that the result contains the given message +@abstract func contains_message(expected: String) -> GdUnitResultAssert + + +## Verifies that the result contains the given value +@abstract func is_value(expected: Variant) -> GdUnitResultAssert diff --git a/addons/gdUnit4/src/GdUnitResultAssert.gd.uid b/addons/gdUnit4/src/GdUnitResultAssert.gd.uid new file mode 100644 index 0000000..be1d38c --- /dev/null +++ b/addons/gdUnit4/src/GdUnitResultAssert.gd.uid @@ -0,0 +1 @@ +uid://djbyj2882ht2s diff --git a/addons/gdUnit4/src/GdUnitSceneRunner.gd b/addons/gdUnit4/src/GdUnitSceneRunner.gd new file mode 100644 index 0000000..9b156f3 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitSceneRunner.gd @@ -0,0 +1,350 @@ +## The Scene Runner is a tool used for simulating interactions on a scene. +## With this tool, you can simulate input events such as keyboard or mouse input and/or simulate scene processing over a certain number of frames. +## This tool is typically used for integration testing a scene. +@abstract class_name GdUnitSceneRunner +extends RefCounted + + +## Simulates that an action has been pressed.[br] +## [member action] : the action e.g. [code]"ui_up"[/code][br] +## [member event_index] : [url=https://docs.godotengine.org/en/4.4/classes/class_inputeventaction.html#class-inputeventaction-property-event-index]default=-1[/url][br] +@abstract func simulate_action_pressed(action: String, event_index := -1) -> GdUnitSceneRunner + + +## Simulates that an action is pressed.[br] +## [member action] : the action e.g. [code]"ui_up"[/code][br] +## [member event_index] : [url=https://docs.godotengine.org/en/4.4/classes/class_inputeventaction.html#class-inputeventaction-property-event-index]default=-1[/url][br] +@abstract func simulate_action_press(action: String, event_index := -1) -> GdUnitSceneRunner + + +## Simulates that an action has been released.[br] +## [member action] : the action e.g. [code]"ui_up"[/code][br] +## [member event_index] : [url=https://docs.godotengine.org/en/4.4/classes/class_inputeventaction.html#class-inputeventaction-property-event-index]default=-1[/url][br] +@abstract func simulate_action_release(action: String, event_index := -1) -> GdUnitSceneRunner + + +## Simulates that a key has been pressed.[br] +## @deprecated: the modifier [b]shift_pressed[/b] and [b]ctrl_pressed[/b] will be removed in v7.0 +## [member key_code] : the key code e.g. [constant KEY_ENTER][br] +## [codeblock] +## func test_key_presssed(): +## var runner = scene_runner("res://scenes/simple_scene.tscn") +## await runner.simulate_key_pressed(KEY_SPACE) +## [/codeblock] +@abstract func simulate_key_pressed(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner + + +## Simulates that a key is pressing.[br] +## @deprecated: the modifier [b]shift_pressed[/b] and [b]ctrl_pressed[/b] will be removed in v7.0[br]See `test_key_shift_and_A_presssing` for example using key combinations +## [member key_code] : the key code e.g. [constant KEY_ENTER][br] +## [codeblock] +## # Do simulate key pressing A +## func test_key_A_presssing(): +## var runner = scene_runner("res://scenes/simple_scene.tscn") +## await runner.simulate_key_press(KEY_A) +## +## +## # Do simulate keycombination pressing shift+A +## func test_key_shift_and_A_presssing(): +## var runner = scene_runner("res://scenes/simple_scene.tscn") +## runner.simulate_key_press(KEY_SHIFT) +## runner.simulate_key_press(KEY_A) +## await _runner.await_input_processed() +## [/codeblock] +@abstract func simulate_key_press(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner + + +## Simulates that a key has been released.[br] +## [member key_code] : the key code e.g. [constant KEY_ENTER][br] +## [codeblock] +## # Do simulate releasing key A +## func test_key_A_releasing(): +## var runner = scene_runner("res://scenes/simple_scene.tscn") +## await runner.simulate_key_release(KEY_A) +## +## +## # Do simulate keycombination pressing shift+A +## func test_key_shift_and_A_releasing((): +## var runner = scene_runner("res://scenes/simple_scene.tscn") +## runner.simulate_key_release(KEY_SHIFT) +## runner.simulate_key_release(KEY_A) +## await _runner.await_input_processed() +## [/codeblock] +## @deprecated: the modifier [b]shift_pressed[/b] and [b]ctrl_pressed[/b] will be removed in v7.0[br]See `test_key_shift_and_A_releasing` for example using key combinations +@abstract func simulate_key_release(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner + + +## Sets the mouse position to the specified vector, provided in pixels and relative to an origin at the upper left corner of the currently focused Window Manager game window.[br] +## [member position] : The absolute position in pixels as Vector2 +@abstract func set_mouse_position(position: Vector2) -> GdUnitSceneRunner + + +## Returns the mouse's position in this Viewport using the coordinate system of this Viewport. +@abstract func get_mouse_position() -> Vector2 + + +## Gets the current global mouse position of the current window +@abstract func get_global_mouse_position() -> Vector2 + + +## Simulates a mouse moved to final position.[br] +## [member position] : The final mouse position +@abstract func simulate_mouse_move(position: Vector2) -> GdUnitSceneRunner + + +## Simulates a mouse move to the relative coordinates (offset).[br] +## [color=yellow]You must use [b]await[/b] to wait until the simulated mouse movement is complete.[/color][br] +## [br] +## [member relative] : The relative position, indicating the mouse position offset.[br] +## [member time] : The time to move the mouse by the relative position in seconds (default is 1 second).[br] +## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br] +## [codeblock] +## func test_move_mouse(): +## var runner = scene_runner("res://scenes/simple_scene.tscn") +## await runner.simulate_mouse_move_relative(Vector2(100,100)) +## [/codeblock] +@abstract func simulate_mouse_move_relative(relative: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner + + +## Simulates a mouse move to the absolute coordinates.[br] +## [color=yellow]You must use [b]await[/b] to wait until the simulated mouse movement is complete.[/color][br] +## [br] +## [member position] : The final position of the mouse.[br] +## [member time] : The time to move the mouse to the final position in seconds (default is 1 second).[br] +## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br] +## [codeblock] +## func test_move_mouse(): +## var runner = scene_runner("res://scenes/simple_scene.tscn") +## await runner.simulate_mouse_move_absolute(Vector2(100,100)) +## [/codeblock] +@abstract func simulate_mouse_move_absolute(position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner + + +## Simulates a mouse button pressed.[br] +## [member button_index] : The mouse button identifier, one of the [enum MouseButton] or button wheel constants. +## [member double_click] : Set to true to simulate a double-click +@abstract func simulate_mouse_button_pressed(button_index: MouseButton, double_click := false) -> GdUnitSceneRunner + + +## Simulates a mouse button press (holding)[br] +## [member button_index] : The mouse button identifier, one of the [enum MouseButton] or button wheel constants. +## [member double_click] : Set to true to simulate a double-click +@abstract func simulate_mouse_button_press(button_index: MouseButton, double_click := false) -> GdUnitSceneRunner + + +## Simulates a mouse button released.[br] +## [member button_index] : The mouse button identifier, one of the [enum MouseButton] or button wheel constants. +@abstract func simulate_mouse_button_release(button_index: MouseButton) -> GdUnitSceneRunner + + +## Simulates a screen touch is pressed.[br] +## [member index] : The touch index in the case of a multi-touch event.[br] +## [member position] : The position to touch the screen.[br] +## [member double_tap] : If true, the touch's state is a double tab. +@abstract func simulate_screen_touch_pressed(index: int, position: Vector2, double_tap := false) -> GdUnitSceneRunner + + +## Simulates a screen touch press without releasing it immediately, effectively simulating a "hold" action.[br] +## [member index] : The touch index in the case of a multi-touch event.[br] +## [member position] : The position to touch the screen.[br] +## [member double_tap] : If true, the touch's state is a double tab. +@abstract func simulate_screen_touch_press(index: int, position: Vector2, double_tap := false) -> GdUnitSceneRunner + + +## Simulates a screen touch is released.[br] +## [member index] : The touch index in the case of a multi-touch event.[br] +## [member double_tap] : If true, the touch's state is a double tab. +@abstract func simulate_screen_touch_release(index: int, double_tap := false) -> GdUnitSceneRunner + + +## Simulates a touch drag and drop event to a relative position.[br] +## [color=yellow]You must use [b]await[/b] to wait until the simulated drag&drop is complete.[/color][br] +## [br] +## [member index] : The touch index in the case of a multi-touch event.[br] +## [member relative] : The relative position, indicating the drag&drop position offset.[br] +## [member time] : The time to move to the relative position in seconds (default is 1 second).[br] +## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br] +## [codeblock] +## func test_touch_drag_drop(): +## var runner = scene_runner("res://scenes/simple_scene.tscn") +## # start drag at position 50,50 +## runner.simulate_screen_touch_drag_begin(1, Vector2(50, 50)) +## # and drop it at final at 150,50 relative (50,50 + 100,0) +## await runner.simulate_screen_touch_drag_relative(1, Vector2(100,0)) +## [/codeblock] +@abstract func simulate_screen_touch_drag_relative(index: int, relative: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner + + +## Simulates a touch screen drop to the absolute coordinates (offset).[br] +## [color=yellow]You must use [b]await[/b] to wait until the simulated drop is complete.[/color][br] +## [br] +## [member index] : The touch index in the case of a multi-touch event.[br] +## [member position] : The final position, indicating the drop position.[br] +## [member time] : The time to move to the final position in seconds (default is 1 second).[br] +## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br] +## [codeblock] +## func test_touch_drag_drop(): +## var runner = scene_runner("res://scenes/simple_scene.tscn") +## # start drag at position 50,50 +## runner.simulate_screen_touch_drag_begin(1, Vector2(50, 50)) +## # and drop it at 100,50 +## await runner.simulate_screen_touch_drag_absolute(1, Vector2(100,50)) +## [/codeblock] +@abstract func simulate_screen_touch_drag_absolute(index: int, position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner + + +## Simulates a complete drag and drop event from one position to another.[br] +## This is ideal for testing complex drag-and-drop scenarios that require a specific start and end position.[br] +## [color=yellow]You must use [b]await[/b] to wait until the simulated drop is complete.[/color][br] +## [br] +## [member index] : The touch index in the case of a multi-touch event.[br] +## [member position] : The drag start position, indicating the drag position.[br] +## [member drop_position] : The drop position, indicating the drop position.[br] +## [member time] : The time to move to the final position in seconds (default is 1 second).[br] +## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br] +## [codeblock] +## func test_touch_drag_drop(): +## var runner = scene_runner("res://scenes/simple_scene.tscn") +## # start drag at position 50,50 and drop it at 100,50 +## await runner.simulate_screen_touch_drag_drop(1, Vector2(50, 50), Vector2(100,50)) +## [/codeblock] +@abstract func simulate_screen_touch_drag_drop(index: int, position: Vector2, drop_position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner + + +## Simulates a touch screen drag event to given position.[br] +## [member index] : The touch index in the case of a multi-touch event.[br] +## [member position] : The drag start position, indicating the drag position.[br] +@abstract func simulate_screen_touch_drag(index: int, position: Vector2) -> GdUnitSceneRunner + + +## Returns the actual position of the touchscreen drag position by given index. +## [member index] : The touch index in the case of a multi-touch event.[br] +@abstract func get_screen_touch_drag_position(index: int) -> Vector2 + + +## Sets how fast or slow the scene simulation is processed (clock ticks versus the real).[br] +## It defaults to 1.0. A value of 2.0 means the game moves twice as fast as real life, +## whilst a value of 0.5 means the game moves at half the regular speed. +## [member time_factor] : A float representing the simulation speed.[br] +## - Default is 1.0, meaning the simulation runs at normal speed.[br] +## - A value of 2.0 means the simulation runs twice as fast as real time.[br] +## - A value of 0.5 means the simulation runs at half the regular speed.[br] +@abstract func set_time_factor(time_factor: float = 1.0) -> GdUnitSceneRunner + + +## Simulates scene processing for a certain number of frames.[br] +## [member frames] : amount of frames to process[br] +## [member delta_milli] : the time delta between a frame in milliseconds +@abstract func simulate_frames(frames: int, delta_milli: int = -1) -> GdUnitSceneRunner + + +## Simulates scene processing until the given signal is emitted by the scene.[br] +## [member signal_name] : the signal to stop the simulation[br] +## [member args] : optional signal arguments to be matched for stop[br] +@abstract func simulate_until_signal(signal_name: String, ...args: Array) -> GdUnitSceneRunner + + +## Simulates scene processing until the given signal is emitted by the given object.[br] +## [member source] : the object that should emit the signal[br] +## [member signal_name] : the signal to stop the simulation[br] +## [member args] : optional signal arguments to be matched for stop +@abstract func simulate_until_object_signal(source: Object, signal_name: String, ...args: Array) -> GdUnitSceneRunner + + +## Waits for all input events to be processed by flushing any buffered input events +## and then awaiting a full cycle of both the process and physics frames.[br] +## [br] +## This is typically used to ensure that any simulated or queued inputs are fully +## processed before proceeding with the next steps in the scene.[br] +## It's essential for reliable input simulation or when synchronizing logic based +## on inputs.[br] +## +## Usage Example: +## [codeblock] +## await await_input_processed() # Ensure all inputs are processed before continuing +## [/codeblock] +@abstract func await_input_processed() -> void + + +## The await_func function pauses execution until a specified function in the scene returns a value.[br] +## It returns a [GdUnitFuncAssert], which provides a suite of assertion methods to verify the returned value.[br] +## [member func_name] : The name of the function to wait for.[br] +## [member args] : Optional function arguments +## [br] +## Usage Example: +## [codeblock] +## # Waits for 'calculate_score' function and verifies the result is equal to 100. +## await_func("calculate_score").is_equal(100) +## [/codeblock] +@abstract func await_func(func_name: String, ...args: Array) -> GdUnitFuncAssert + + +## The await_func_on function extends the functionality of await_func by allowing you to specify a source node within the scene.[br] +## It waits for a specified function on that node to return a value and returns a [GdUnitFuncAssert] object for assertions.[br] +## [member source] : The object where implements the function.[br] +## [member func_name] : The name of the function to wait for.[br] +## [member args] : optional function arguments +## [br] +## Usage Example: +## [codeblock] +## # Waits for 'calculate_score' function and verifies the result is equal to 100. +## var my_instance := ScoreCalculator.new() +## await_func(my_instance, "calculate_score").is_equal(100) +## [/codeblock] +@abstract func await_func_on(source: Object, func_name: String, ...args: Array) -> GdUnitFuncAssert + + +## Waits for the specified signal to be emitted by the scene. If the signal is not emitted within the given timeout, the operation fails.[br] +## [member signal_name] : The name of the signal to wait for[br] +## [member args] : The signal arguments as an array[br] +## [member timeout] : The maximum duration (in milliseconds) to wait for the signal to be emitted before failing +@abstract func await_signal(signal_name: String, args := [], timeout := 2000 ) -> void + + +## Waits for the specified signal to be emitted by a particular source node. If the signal is not emitted within the given timeout, the operation fails.[br] +## [member source] : the object from which the signal is emitted[br] +## [member signal_name] : The name of the signal to wait for[br] +## [member args] : The signal arguments as an array[br] +## [member timeout] : tThe maximum duration (in milliseconds) to wait for the signal to be emitted before failing +@abstract func await_signal_on(source: Object, signal_name: String, args := [], timeout := 2000 ) -> void + + +## Restores the scene window to a windowed mode and brings it to the foreground.[br] +## This ensures that the scene is visible and active during testing, making it easier to observe and interact with. +@abstract func move_window_to_foreground() -> GdUnitSceneRunner + + +## Minimizes the scene window to a windowed mode and brings it to the background.[br] +## This ensures that the scene is hidden during testing. +@abstract func move_window_to_background() -> GdUnitSceneRunner + + +## Return the current value of the property with the name .[br] +## [member name] : name of property[br] +## [member return] : the value of the property +@abstract func get_property(name: String) -> Variant + + +## Set the value of the property with the name .[br] +## [member name] : name of property[br] +## [member value] : value of property[br] +## [member return] : true|false depending on valid property name. +@abstract func set_property(name: String, value: Variant) -> bool + + +## executes the function specified by in the scene and returns the result.[br] +## [member name] : the name of the function to execute[br] +## [member args] : optional function arguments[br] +## [member return] : the function result +@abstract func invoke(name: String, ...args: Array) -> Variant + + +## Searches for the specified node with the name in the current scene and returns it, otherwise null.[br] +## [member name] : the name of the node to find[br] +## [member recursive] : enables/disables seraching recursive[br] +## [member return] : the node if find otherwise null +@abstract func find_child(name: String, recursive: bool = true, owned: bool = false) -> Node + + +## Access to current running scene +@abstract func scene() -> Node diff --git a/addons/gdUnit4/src/GdUnitSceneRunner.gd.uid b/addons/gdUnit4/src/GdUnitSceneRunner.gd.uid new file mode 100644 index 0000000..6f81b9f --- /dev/null +++ b/addons/gdUnit4/src/GdUnitSceneRunner.gd.uid @@ -0,0 +1 @@ +uid://cd83xyqmgeb3n diff --git a/addons/gdUnit4/src/GdUnitSignalAssert.gd b/addons/gdUnit4/src/GdUnitSignalAssert.gd new file mode 100644 index 0000000..2c3d39b --- /dev/null +++ b/addons/gdUnit4/src/GdUnitSignalAssert.gd @@ -0,0 +1,158 @@ +## An Assertion Tool to verify for emitted signals until a waiting time +@abstract class_name GdUnitSignalAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitSignalAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitSignalAssert + + +## Verifies that the current value is equal to the given one. +@abstract func is_equal(expected: Variant) -> GdUnitSignalAssert + + +## Verifies that the current value is not equal to expected one. +@abstract func is_not_equal(expected: Variant) -> GdUnitSignalAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitSignalAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitSignalAssert + + +## Verifies that the specified signal is emitted with the expected arguments.[br] +## +## This assertion waits for a signal to be emitted from the object under test and +## validates that it was emitted with the correct arguments. The function supports +## both typed signals (Signal type) and string-based signal names for flexibility +## in different testing scenarios.[br] +## [br] +## [b]Parameters:[/b][br] +## [param signal_name]: The signal to monitor. Can be either:[br] +## • A [Signal] reference (recommended for type safety)[br] +## • A [String] with the signal name +## [param signal_args]: Optional expected signal arguments.[br] +## When provided, verifies the signal was emitted with exactly these values.[br] +## [br] +## [b]Returns:[/b][br] +## [GdUnitSignalAssert] - Returns self for method chaining.[br] +## [br] +## [b]Examples:[/b] +## [codeblock] +## signal signal_a(value: int) +## signal signal_b(name: String, count: int) +## +## # Wait for signal emission without checking arguments +## # Using Signal reference (type-safe) +## await assert_signal(instance).is_emitted(signal_a) +## # Using string name (dynamic) +## await assert_signal(instance).is_emitted("signal_a") +## +## # Wait for signal emission with specific argument +## await assert_signal(instance).is_emitted(signal_a, 10) +## +## # Wait for signal with multiple arguments +## await assert_signal(instance).is_emitted(signal_b, "test", 42) +## +## # Wait max 500ms for signal with argument 10 +## await assert_signal(instance).wait_until(500).is_emitted(signal_a, 10) +## [/codeblock] +## [br] +## [b]Note:[/b] This is an async operation - use [code]await[/code] when calling.[br] +## The assertion fails if the signal is not emitted within the timeout period. +@abstract func is_emitted(signal_name: Variant, ...signal_args: Array) -> GdUnitSignalAssert + + +## Verifies that the specified signal is NOT emitted with the expected arguments.[br] +## +## This assertion waits for a specified time period and validates that a signal +## was not emitted with the given arguments. Useful for ensuring certain conditions +## don't trigger unwanted signals or for verifying signal filtering logic.[br] +## [br] +## [b]Parameters:[/b][br] +## [param signal_name]: The signal to monitor. Can be either:[br] +## • A [Signal] reference (recommended for type safety)[br] +## • A [String] with the signal name +## [param signal_args]: Optional expected signal arguments.[br] +## When provided, verifies the signal was not emitted with these specific values.[br] +## If omitted, verifies the signal was not emitted at all.[br] +## [br] +## [b]Returns:[/b][br] +## [GdUnitSignalAssert] - Returns self for method chaining.[br] +## [br] +## [b]Examples:[/b] +## [codeblock] +## signal signal_a(value: int) +## signal signal_b(name: String, count: int) +## +## # Verify signal is not emitted at all (without checking arguments) +## await assert_signal(instance).wait_until(500).is_not_emitted(signal_a) +## await assert_signal(instance).wait_until(500).is_not_emitted("signal_a") +## +## # Verify signal is not emitted with specific argument +## await assert_signal(instance).wait_until(500).is_not_emitted(signal_a, 10) +## +## # Verify signal is not emitted with multiple arguments +## await assert_signal(instance).wait_until(500).is_not_emitted(signal_b, "test", 42) +## +## # Can be emitted with different arguments (this passes) +## instance.emit_signal("signal_a", 20) # Emits with 20, not 10 +## await assert_signal(instance).wait_until(500).is_not_emitted(signal_a, 10) +## [/codeblock] +## [br] +## [b]Note:[/b] This is an async operation - use [code]await[/code] when calling.[br] +## The assertion fails if the signal IS emitted with the specified arguments within the timeout period. +@abstract func is_not_emitted(signal_name: Variant, ...signal_args: Array) -> GdUnitSignalAssert + + +## Verifies that the specified signal exists on the emitter object.[br] +## +## This assertion checks if a signal is defined on the object under test, +## regardless of whether it has been emitted. Useful for validating that +## objects have the expected signals before testing their emission.[br] +## [br] +## [b]Parameters:[/b][br] +## [param signal_name]: The signal to check. Can be either:[br] +## • A [Signal] reference (recommended for type safety)[br] +## • A [String] with the signal name +## [br] +## [b]Returns:[/b][br] +## [GdUnitSignalAssert] - Returns self for method chaining.[br] +## [br] +## [b]Examples:[/b] +## [codeblock] +## signal my_signal(value: int) +## signal another_signal() +## +## # Verify signal exists using Signal reference +## assert_signal(instance).is_signal_exists(my_signal) +## +## # Verify signal exists using string name +## assert_signal(instance).is_signal_exists("my_signal") +## +## # Chain with other assertions +## assert_signal(instance) \ +## .is_signal_exists(my_signal) \ +## .is_emitted(my_signal, 42) +## +## [/codeblock] +## [br] +## [b]Note:[/b] This only checks signal definition, not emission.[br] +## The assertion fails if the signal is not defined on the object. +@abstract func is_signal_exists(signal_name: Variant) -> GdUnitSignalAssert + + +## Sets the assert signal timeout in ms, if the time over a failure is reported.[br] +## Example: +## [codeblock] +## do wait until 5s the instance has emitted the signal `signal_a`[br] +## assert_signal(instance).wait_until(5000).is_emitted("signal_a") +## [/codeblock] +@abstract func wait_until(timeout: int) -> GdUnitSignalAssert diff --git a/addons/gdUnit4/src/GdUnitSignalAssert.gd.uid b/addons/gdUnit4/src/GdUnitSignalAssert.gd.uid new file mode 100644 index 0000000..7ea0603 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitSignalAssert.gd.uid @@ -0,0 +1 @@ +uid://dnno7sv5ruwws diff --git a/addons/gdUnit4/src/GdUnitStringAssert.gd b/addons/gdUnit4/src/GdUnitStringAssert.gd new file mode 100644 index 0000000..2de698b --- /dev/null +++ b/addons/gdUnit4/src/GdUnitStringAssert.gd @@ -0,0 +1,71 @@ +## An Assertion Tool to verify String values +@abstract class_name GdUnitStringAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitStringAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitStringAssert + + +## Verifies that the current value is equal to the given one. +@abstract func is_equal(expected: Variant) -> GdUnitStringAssert + + +## Verifies that the current String is equal to the given one, ignoring case considerations. +@abstract func is_equal_ignoring_case(expected: Variant) -> GdUnitStringAssert + + +## Verifies that the current value is not equal to expected one. +@abstract func is_not_equal(expected: Variant) -> GdUnitStringAssert + + +## Verifies that the current String is not equal to the given one, ignoring case considerations. +@abstract func is_not_equal_ignoring_case(expected: Variant) -> GdUnitStringAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitStringAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitStringAssert + + +## Verifies that the current String is empty, it has a length of 0. +@abstract func is_empty() -> GdUnitStringAssert + + +## Verifies that the current String is not empty, it has a length of minimum 1. +@abstract func is_not_empty() -> GdUnitStringAssert + + +## Verifies that the current String contains the given String. +@abstract func contains(expected: String) -> GdUnitStringAssert + + +## Verifies that the current String does not contain the given String. +@abstract func not_contains(expected: String) -> GdUnitStringAssert + + +## Verifies that the current String does not contain the given String, ignoring case considerations. +@abstract func contains_ignoring_case(expected: String) -> GdUnitStringAssert + + +## Verifies that the current String does not contain the given String, ignoring case considerations. +@abstract func not_contains_ignoring_case(expected: String) -> GdUnitStringAssert + + +## Verifies that the current String starts with the given prefix. +@abstract func starts_with(expected: String) -> GdUnitStringAssert + + +## Verifies that the current String ends with the given suffix. +@abstract func ends_with(expected: String) -> GdUnitStringAssert + + +## Verifies that the current String has the expected length by used comparator. +@abstract func has_length(length: int, comparator: int = Comparator.EQUAL) -> GdUnitStringAssert diff --git a/addons/gdUnit4/src/GdUnitStringAssert.gd.uid b/addons/gdUnit4/src/GdUnitStringAssert.gd.uid new file mode 100644 index 0000000..d11816a --- /dev/null +++ b/addons/gdUnit4/src/GdUnitStringAssert.gd.uid @@ -0,0 +1 @@ +uid://c0l4juv45771a diff --git a/addons/gdUnit4/src/GdUnitTestSuite.gd b/addons/gdUnit4/src/GdUnitTestSuite.gd new file mode 100644 index 0000000..0a5f5fe --- /dev/null +++ b/addons/gdUnit4/src/GdUnitTestSuite.gd @@ -0,0 +1,774 @@ +## The main class for all GdUnit test suites[br] +## This class is the main class to implement your unit tests[br] +## You have to extend and implement your test cases as described[br] +## e.g MyTests.gd [br] +## [codeblock] +## extends GdUnitTestSuite +## # testcase +## func test_case_a(): +## assert_that("value").is_equal("value") +## [/codeblock] +## @tutorial: https://mikeschulze.github.io/gdUnit4/faq/test-suite/ + +@icon("res://addons/gdUnit4/src/ui/settings/logo.png") +class_name GdUnitTestSuite +extends Node + + +### internal runtime variables that must not be overwritten!!! +@warning_ignore("unused_private_class_variable") +var __is_skipped := false +@warning_ignore("unused_private_class_variable") +var __skip_reason := "Unknow." +var __active_test_case: String +var __awaiter := __gdunit_awaiter() + + +### We now load all used asserts and tool scripts into the cache according to the principle of "lazy loading" +### in order to noticeably reduce the loading time of the test suite. +# We go this hard way to increase the loading performance to avoid reparsing all the used scripts +# for more detailed info -> https://github.com/godotengine/godot/issues/67400 +static func __lazy_load(script_path: String) -> GDScript: + return GdUnitAssertions.__lazy_load(script_path) + + +func __gdunit_assert() -> GDScript: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd") + + +func __gdunit_tools() -> GDScript: + return __lazy_load("res://addons/gdUnit4/src/core/GdUnitTools.gd") + + +func __gdunit_file_access() -> GDScript: + return __lazy_load("res://addons/gdUnit4/src/core/GdUnitFileAccess.gd") + + +func __gdunit_awaiter() -> Object: + return __lazy_load("res://addons/gdUnit4/src/GdUnitAwaiter.gd").new() + + +func __gdunit_argument_matchers() -> GDScript: + return __lazy_load("res://addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd") + + +func __gdunit_object_interactions() -> GDScript: + return __lazy_load("res://addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd") + + +## This function is called before a test suite starts[br] +## You can overwrite to prepare test data or initalizize necessary variables +func before() -> void: + pass + + +## This function is called at least when a test suite is finished[br] +## You can overwrite to cleanup data created during test running +func after() -> void: + pass + + +## This function is called before a test case starts[br] +## You can overwrite to prepare test case specific data +func before_test() -> void: + pass + + +## This function is called after the test case is finished[br] +## You can overwrite to cleanup your test case specific data +func after_test() -> void: + pass + + +func is_failure() -> bool: + return Engine.get_meta("GD_TEST_FAILURE") if Engine.has_meta("GD_TEST_FAILURE") else false + + +func set_active_test_case(test_case: String) -> void: + __active_test_case = test_case + + +# === Tools ==================================================================== +# Mapps Godot error number to a readable error message. See at ERROR +# https://docs.godotengine.org/de/stable/classes/class_@globalscope.html#enum-globalscope-error +func error_as_string(error_number: int) -> String: + return error_string(error_number) + + +## A litle helper to auto freeing your created objects after test execution +func auto_free(obj: Variant) -> Variant: + var execution_context := GdUnitThreadManager.get_current_context().get_execution_context() + + # If the execution context is null, we are not in the execution call stack, so we mark the object as “queued free.” + if execution_context == null: + if obj is Node: + @warning_ignore("unsafe_method_access") + obj.queue_free() + return obj + + return execution_context.register_auto_free(obj) + + +## Saves the current project settings into the active execution context.[br] +## Useful when the "Auto Save Project Settings" setting is disabled and a test needs settings isolation on demand.[br] +## [br] +## Call this in [code]before()[/code]/[code]before_test()[/code] to snapshot the settings a test +## is about to change, to restore see [method restore_project_settings]. +func save_project_settings() -> void: + var execution_context := GdUnitThreadManager.get_current_context().get_execution_context() + if execution_context == null: + return + execution_context.save_project_settings() + + +## Restores the project settings previously captured by [method save_project_settings].[br] +## [br] +## Call this in [code]after()[/code]/[code]after_test()[/code] to undo any settings a test changed. +func restore_project_settings() -> void: + var execution_context := GdUnitThreadManager.get_current_context().get_execution_context() + if execution_context == null: + return + execution_context.restore_project_settings() + + +@warning_ignore("native_method_override") +func add_child(node: Node, force_readable_name := false, internal := Node.INTERNAL_MODE_DISABLED) -> void: + super.add_child(node, force_readable_name, internal) + var execution_context := GdUnitThreadManager.get_current_context().get_execution_context() + if execution_context != null: + execution_context.orphan_monitor_start() + + +## Discard the error message triggered by a timeout (interruption).[br] +## By default, an interrupted test is reported as an error.[br] +## This function allows you to change the message to Success when an interrupted error is reported. +func discard_error_interupted_by_timeout() -> void: + @warning_ignore("unsafe_method_access") + __gdunit_tools().register_expect_interupted_by_timeout(self, __active_test_case) + + +## Creates a new directory under the temporary directory *user://tmp*[br] +## Useful for storing data during test execution. [br] +## The directory is automatically deleted after test suite execution +func create_temp_dir(relative_path: String) -> String: + @warning_ignore("unsafe_method_access") + return __gdunit_file_access().create_temp_dir(relative_path) + + +## Deletes the temporary base directory[br] +## Is called automatically after each execution of the test suite +func clean_temp_dir() -> void: + @warning_ignore("unsafe_method_access") + __gdunit_file_access().clear_tmp() + + +## Creates a new file under the temporary directory *user://tmp* + [br] +## with given name and given file (default = File.WRITE)[br] +## If success the returned File is automatically closed after the execution of the test suite +func create_temp_file(relative_path: String, file_name: String, mode := FileAccess.WRITE) -> FileAccess: + @warning_ignore("unsafe_method_access") + return __gdunit_file_access().create_temp_file(relative_path, file_name, mode) + + +## Reads a resource by given path into a PackedStringArray. +func resource_as_array(resource_path: String) -> PackedStringArray: + @warning_ignore("unsafe_method_access") + return __gdunit_file_access().resource_as_array(resource_path) + + +## Reads a resource by given path and returned the content as String. +func resource_as_string(resource_path: String) -> String: + @warning_ignore("unsafe_method_access") + return __gdunit_file_access().resource_as_string(resource_path) + + +## Reads a resource by given path and return Variand translated by str_to_var +func resource_as_var(resource_path: String) -> Variant: + @warning_ignore("unsafe_method_access", "unsafe_cast") + return str_to_var(__gdunit_file_access().resource_as_string(resource_path) as String) + + +## Waits for given signal to be emitted by until a specified timeout to fail[br] +## source: the object from which the signal is emitted[br] +## signal_name: signal name[br] +## args: the expected signal arguments as an array[br] +## timeout: the timeout in ms, default is set to 2000ms +func await_signal_on(source: Object, signal_name: String, args: Array = [], timeout: int = 2000) -> Variant: + @warning_ignore("unsafe_method_access") + return await __awaiter.await_signal_on(source, signal_name, args, timeout) + + +## Waits until the next idle frame +func await_idle_frame() -> void: + @warning_ignore("unsafe_method_access") + await __awaiter.await_idle_frame() + + +## Waits for a given amount of milliseconds[br] +## example:[br] +## [codeblock] +## # waits for 100ms +## await await_millis(myNode, 100).completed +## [/codeblock][br] +## use this waiter and not `await get_tree().create_timer().timeout to prevent errors when a test case is timed out +func await_millis(timeout: int) -> void: + @warning_ignore("unsafe_method_access") + await __awaiter.await_millis(timeout) + + +## Collects detailed information about orphaned nodes for debugging purposes.[br] +## +## This function gathers comprehensive details about nodes that remain in memory +## after test execution (orphans). It provides debugging information to help +## identify the source of memory leaks in tests. Must be manually called in +## tests when orphan nodes are detected.[br] +## [br] +## [b]When to Use:[/b][br] +## - When GdUnit4 reports orphan nodes after test execution[br] +## - For debugging memory leaks in test scenarios[br] +## - To get detailed information about unreleased nodes[br] +## [br] +## [b]Usage Pattern:[/b][br] +## Add this call at the end of tests that are suspected to create orphans, +## or when the test runner reports orphan detection.[br] +## [br] +## [b]Examples:[/b] +## [codeblock] +## func test_scene_management(): +## # Test code that might create orphan nodes +## var scene = preload("res://TestScene.tscn").instantiate() +## add_child(scene) +## +## # Do test operations +## scene.some_method() +## +## # Clean up (but might miss some nodes) +## scene.queue_free() +## +## # Collect orphan details if any are detected +## collect_orphan_node_details() +## [/codeblock] +## [br] +## [b]Note:[/b] This is a debugging utility function that should be removed +## or commented out once orphan issues are resolved. +func collect_orphan_node_details() -> void: + GdUnitThreadManager.get_current_context().get_execution_context().orphan_monitor_collect() + + +## Creates a new scene runner to allow simulate interactions checked a scene.[br] +## The runner will manage the scene instance and release after the runner is released[br] +## example:[br] +## [codeblock] +## # creates a runner by using a instanciated scene +## var scene = load("res://foo/my_scne.tscn").instantiate() +## var runner := scene_runner(scene) +## +## # or simply creates a runner by using the scene resource path +## var runner := scene_runner("res://foo/my_scne.tscn") +## [/codeblock] +func scene_runner(scene: Variant, verbose := false) -> GdUnitSceneRunner: + return auto_free(__lazy_load("res://addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd").new(scene, verbose)) + + +# === Mocking & Spy =========================================================== + +## do return a default value for primitive types or null +const RETURN_DEFAULTS = GdUnitMock.RETURN_DEFAULTS +## do call the real implementation +const CALL_REAL_FUNC = GdUnitMock.CALL_REAL_FUNC +## do return a default value for primitive types and a fully mocked value for Object types +## builds full deep mocked object +const RETURN_DEEP_STUB = GdUnitMock.RETURN_DEEP_STUB + + +## Creates a mock for given class name +func mock(clazz: Variant, mock_mode := RETURN_DEFAULTS) -> Variant: + @warning_ignore("unsafe_method_access") + return __lazy_load("res://addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd").build(clazz, mock_mode) + + +## Creates a spy checked given object instance +func spy(instance: Variant) -> Variant: + @warning_ignore("unsafe_method_access") + return __lazy_load("res://addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd").build(instance) + + +## Configures a return value for the specified function and used arguments.[br] +## [b]Example: +## [codeblock] +## # overrides the return value of myMock.is_selected() to false +## do_return(false).on(myMock).is_selected() +## [/codeblock] +func do_return(value: Variant) -> GdUnitMock: + return GdUnitMock.new(value) + + +## Verifies certain behavior happened at least once or exact number of times +func verify(obj: Variant, times := 1) -> Variant: + @warning_ignore("unsafe_method_access") + return __gdunit_object_interactions().verify(obj, times) + + +## Verifies no interactions is happen checked this mock or spy +func verify_no_interactions(obj: Variant) -> GdUnitAssert: + @warning_ignore("unsafe_method_access") + return __gdunit_object_interactions().verify_no_interactions(obj) + + +## Verifies the given mock or spy has any unverified interaction. +func verify_no_more_interactions(obj: Variant) -> GdUnitAssert: + @warning_ignore("unsafe_method_access") + return __gdunit_object_interactions().verify_no_more_interactions(obj) + + +## Resets the saved function call counters checked a mock or spy +func reset(obj: Variant) -> void: + @warning_ignore("unsafe_method_access") + __gdunit_object_interactions().reset(obj) + + +## Starts monitoring the specified source to collect all transmitted signals.[br] +## The collected signals can then be checked with 'assert_signal'.[br] +## By default, the specified source is automatically released when the test ends. +## You can control this behavior by setting auto_free to false if you do not want the source to be automatically freed.[br] +## Usage: +## [codeblock] +## var emitter := monitor_signals(MyEmitter.new()) +## # call the function to send the signal +## emitter.do_it() +## # verify the signial is emitted +## await assert_signal(emitter).is_emitted('my_signal') +## [/codeblock] +func monitor_signals(source: Object, _auto_free := true) -> Object: + @warning_ignore("unsafe_method_access") + __lazy_load("res://addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd")\ + .get_current_context()\ + .get_signal_collector()\ + .register_emitter(source, true) # force recreate to start with a fresh monitoring + return auto_free(source) if _auto_free else source + + +# === Argument matchers ======================================================== +## Argument matcher to match any argument +func any() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().any() + + +## Argument matcher to match any boolean value +func any_bool() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_BOOL) + + +## Argument matcher to match any integer value +func any_int() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_INT) + + +## Argument matcher to match any float value +func any_float() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_FLOAT) + + +## Argument matcher to match any String value +func any_string() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_STRING) + + +## Argument matcher to match any Color value +func any_color() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_COLOR) + + +## Argument matcher to match any Vector typed value +func any_vector() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_types([ + TYPE_VECTOR2, + TYPE_VECTOR2I, + TYPE_VECTOR3, + TYPE_VECTOR3I, + TYPE_VECTOR4, + TYPE_VECTOR4I, + ]) + + +## Argument matcher to match any Vector2 value +func any_vector2() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_VECTOR2) + + +## Argument matcher to match any Vector2i value +func any_vector2i() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_VECTOR2I) + + +## Argument matcher to match any Vector3 value +func any_vector3() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_VECTOR3) + + +## Argument matcher to match any Vector3i value +func any_vector3i() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_VECTOR3I) + + +## Argument matcher to match any Vector4 value +func any_vector4() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_VECTOR4) + + +## Argument matcher to match any Vector4i value +func any_vector4i() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_VECTOR4I) + + +## Argument matcher to match any Rect2 value +func any_rect2() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_RECT2) + + +## Argument matcher to match any Plane value +func any_plane() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PLANE) + + +## Argument matcher to match any Quaternion value +func any_quat() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_QUATERNION) + + +## Argument matcher to match any AABB value +func any_aabb() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_AABB) + + +## Argument matcher to match any Basis value +func any_basis() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_BASIS) + + +## Argument matcher to match any Transform2D value +func any_transform_2d() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_TRANSFORM2D) + + +## Argument matcher to match any Transform3D value +func any_transform_3d() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_TRANSFORM3D) + + +## Argument matcher to match any NodePath value +func any_node_path() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_NODE_PATH) + + +## Argument matcher to match any RID value +func any_rid() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_RID) + + +## Argument matcher to match any Object value +func any_object() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_OBJECT) + + +## Argument matcher to match any Dictionary value +func any_dictionary() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_DICTIONARY) + + +## Argument matcher to match any Array value +func any_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_ARRAY) + + +## Argument matcher to match any PackedByteArray value +func any_packed_byte_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_BYTE_ARRAY) + + +## Argument matcher to match any PackedInt32Array value +func any_packed_int32_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_INT32_ARRAY) + + +## Argument matcher to match any PackedInt64Array value +func any_packed_int64_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_INT64_ARRAY) + + +## Argument matcher to match any PackedFloat32Array value +func any_packed_float32_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_FLOAT32_ARRAY) + + +## Argument matcher to match any PackedFloat64Array value +func any_packed_float64_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_FLOAT64_ARRAY) + + +## Argument matcher to match any PackedStringArray value +func any_packed_string_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_STRING_ARRAY) + + +## Argument matcher to match any PackedVector2Array value +func any_packed_vector2_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_VECTOR2_ARRAY) + + +## Argument matcher to match any PackedVector3Array value +func any_packed_vector3_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_VECTOR3_ARRAY) + + +## Argument matcher to match any PackedColorArray value +func any_packed_color_array() -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().by_type(TYPE_PACKED_COLOR_ARRAY) + + +## Argument matcher to match any instance of given class +func any_class(clazz :Object) -> GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + return __gdunit_argument_matchers().any_class(clazz) + + +# === value extract utils ====================================================== +## Builds an extractor by given function name and optional arguments +func extr(func_name: String, args := Array()) -> GdUnitValueExtractor: + return __lazy_load("res://addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd").new(func_name, args) + + +## Creates a GdUnitTuple from the provided arguments for use in test assertions. +## [br] +## This is the primary helper function for creating tuples in GdUnit4 tests. +## It provides a convenient way to group multiple expected values when using +## [method extractv] assertions. The function enforces that tuples must contain +## at least two values, as single-value extractions don't require tuple grouping. +## [br] +## [b]Parameters:[/b] [br] +## - [code]...args[/code]: Variable number of arguments (minimum 2) to group into a tuple. +## Each argument represents a value to be compared in assertions. +## [br] +## [b]Returns:[/b] [br] +## A [GdUnitTuple] containing the provided values, or an empty tuple if fewer than +## 2 arguments are provided (with an error message). +## [br] +## [b]Error Handling:[/b] [br] +## [codeblock] +## # This will push an error and return empty tuple +## var invalid = tuple("single_value") # Error: requires at least 2 arguments +## [br] +## # Correct usage - minimum 2 arguments +## var valid = tuple("name", "value") +## var valid_multi = tuple(1, 2, 3, 4, 5) # Can have many values +## [/codeblock] +func tuple(...args: Array) -> GdUnitTuple: + if args.size() < 2: + push_error("Tuple requires at least two arguments.") + return GdUnitTuple.new() + return GdUnitTuple.new.callv(args) + + +# === Asserts ================================================================== + +## The common assertion tool to verify values. +## It checks the given value by type to fit to the best assert +func assert_that(current: Variant) -> GdUnitAssert: + match typeof(current): + TYPE_BOOL: + return assert_bool(current) + TYPE_INT: + return assert_int(current) + TYPE_FLOAT: + return assert_float(current) + TYPE_STRING: + return assert_str(current) + TYPE_VECTOR2, TYPE_VECTOR2I, TYPE_VECTOR3, TYPE_VECTOR3I, TYPE_VECTOR4, TYPE_VECTOR4I: + return assert_vector(current, false) + TYPE_DICTIONARY: + return assert_dict(current) + TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY,\ + TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY,\ + TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_COLOR_ARRAY: + return assert_array(current, false) + TYPE_OBJECT, TYPE_NIL: + return assert_object(current) + _: + return __gdunit_assert().new(current) + + +## An assertion tool to verify boolean values. +static func assert_bool(current: Variant) -> GdUnitBoolAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd").new(current) + + +## An assertion tool to verify String values. +func assert_str(current: Variant) -> GdUnitStringAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd").new(current) + + +## An assertion tool to verify integer values. +func assert_int(current: Variant) -> GdUnitIntAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd").new(current) + + +## An assertion tool to verify float values. +func assert_float(current: Variant) -> GdUnitFloatAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd").new(current) + + +## An assertion tool to verify Vector values.[br] +## This assertion supports all vector types.[br] +## Usage: +## [codeblock] +## assert_vector(Vector2(1.2, 1.000001)).is_equal(Vector2(1.2, 1.000001)) +## [/codeblock] +func assert_vector(current: Variant, type_check := true) -> GdUnitVectorAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd").new(current, type_check) + + +## An assertion tool to verify arrays. +func assert_array(current: Variant, type_check := true) -> GdUnitArrayAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd").new(current, type_check) + + +## An assertion tool to verify dictionaries. +func assert_dict(current: Variant) -> GdUnitDictionaryAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd").new(current) + + +## An assertion tool to verify FileAccess. +func assert_file(current: Variant) -> GdUnitFileAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd").new(current) + + +## An assertion tool to verify Objects. +func assert_object(current: Variant) -> GdUnitObjectAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd").new(current) + + +func assert_result(current: Variant) -> GdUnitResultAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd").new(current) + + +## An assertion tool that waits until a certain time for an expected function return value +func assert_func(instance: Object, func_name: String, args := Array()) -> GdUnitFuncAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd").new(instance, func_name, args) + + +## An assertion tool to verify for emitted signals until a certain time. +func assert_signal(instance: Object) -> GdUnitSignalAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd").new(instance) + + +## An assertion tool to test for failing assertions.[br] +## This assert is only designed for internal use to verify failing asserts working as expected.[br] +## Usage: +## [codeblock] +## assert_failure(func(): assert_bool(true).is_not_equal(true)) \ +## .has_message("Expecting:\n 'true'\n not equal to\n 'true'") +## [/codeblock] +func assert_failure(assertion: Callable) -> GdUnitFailureAssert: + @warning_ignore("unsafe_method_access") + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd").new().execute(assertion) + + +## An assertion tool to test for failing assertions.[br] +## This assert is only designed for internal use to verify failing asserts working as expected.[br] +## Usage: +## [codeblock] +## await assert_failure_await(func(): assert_bool(true).is_not_equal(true)) \ +## .has_message("Expecting:\n 'true'\n not equal to\n 'true'") +## [/codeblock] +func assert_failure_await(assertion: Callable) -> GdUnitFailureAssert: + @warning_ignore("unsafe_method_access") + return await __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd").new().execute_and_await(assertion) + + +## An assertion tool to verify Godot errors.[br] +## You can use to verify certain Godot errors like failing assertions, push_error, push_warn.[br] +## Usage: +## [codeblock] +## # tests no error occurred during execution of the code +## await assert_error(func (): return 0 )\ +## .is_success() +## +## # tests a push_error('test error') occured during execution of the code +## await assert_error(func (): push_error('test error') )\ +## .is_push_error('test error') +## [/codeblock] +func assert_error(current: Callable) -> GdUnitGodotErrorAssert: + return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd").new(current) + + +## Explicitly fails the current test indicating that the feature is not yet implemented.[br] +## This function is useful during development when you want to write test cases before implementing the actual functionality.[br] +## It provides a clear indication that the test failure is expected because the feature is still under development.[br] +## Usage: +## [codeblock] +## # Test for a feature that will be implemented later +## func test_advanced_ai_behavior(): +## assert_not_yet_implemented() +## +## [/codeblock] +func assert_not_yet_implemented() -> void: + @warning_ignore("unsafe_method_access") + __gdunit_assert().new(null).do_fail_not_implemented() + + +## Explicitly fails the current test with a custom error message.[br] +## This function reports an error but does not terminate test execution automatically.[br] +## You must use 'return' after calling fail() to stop the test since GDScript has no exception support.[br] +## Useful for complex conditional testing scenarios where standard assertions are insufficient.[br] +## Usage: +## [codeblock] +## # Fail test when conditions are not met +## if !custom_check(player): +## fail("Player should be alive but has %d health" % player.health) +## return +## +## # Continue with test if conditions pass +## assert_that(player.health).is_greater(0) +## [/codeblock] +func fail(message: String) -> void: + @warning_ignore("unsafe_method_access") + __gdunit_assert().new(null).do_fail_with_message(message) + + +# --- internal stuff do not override!!! +func ResourcePath() -> String: + return get_script().resource_path diff --git a/addons/gdUnit4/src/GdUnitTestSuite.gd.uid b/addons/gdUnit4/src/GdUnitTestSuite.gd.uid new file mode 100644 index 0000000..6739522 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitTestSuite.gd.uid @@ -0,0 +1 @@ +uid://bqh4y8rpdssrx diff --git a/addons/gdUnit4/src/GdUnitTuple.gd b/addons/gdUnit4/src/GdUnitTuple.gd new file mode 100644 index 0000000..9d56dd8 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitTuple.gd @@ -0,0 +1,86 @@ +## A tuple implementation for GdUnit4 test assertions and value extraction. +## @tutorial(GdUnit4 Array Assertions): https://mikeschulze.github.io/gdUnit4/latest/testing/assert-array/#extractv +## @tutorial(GdUnit4 Testing Framework): https://mikeschulze.github.io/gdUnit4/ +## [br] +## The GdUnitTuple class is a utility container designed specifically for the GdUnit4 +## testing framework. It enables advanced assertion operations, particularly when +## extracting and comparing multiple values from complex test results. +## [br] +## [b]Primary Use Cases in Testing:[/b] [br] +## - Extracting multiple properties from test objects with [method extractv]## [br] +## - Grouping related assertion values for comparison## [br] +## - Returning multiple values from test helper methods## [br] +## - Organizing expected vs actual value pairs in assertions## [br] +## [br] +## [b]Example Usage in Tests:[/b] +## [codeblock] +## func test_player_stats_after_level_up(): +## var player = Player.new() +## player.level_up() +## +## # Extract multiple properties using tuple +## assert_array([player]) \ +## .extractv(extr("name"), extr("level"), extr("hp")) \ +## .contains(tuple("Hero", 2, 150)) +## +## func test_enemy_spawn_positions(): +## var enemies: Array = spawn_enemies(3) +## +## # Verify multiple enemies have correct position data +## assert_array(enemies) \ +## .extractv(extr("position.x"), extr("position.y")) \ +## .contains_exactly([ +## tuple(100, 200), +## tuple(150, 200), +## tuple(200, 200) +## ]) +## [/codeblock] +## [br] +## [b]Integration with GdUnit4 Assertions:[/b] [br] +## Tuples work seamlessly with array assertion methods like: [br] +## - [code]contains()[/code] - Check if extracted values contain specific tuples [br] +## - [code]contains_exactly()[/code] - Verify exact tuple matches [br] +## - [code]is_equal()[/code] - Compare tuple equality [br] +## [br] +## [b]Note:[/b] This class is part of the GdUnit4 testing framework's internal +## utilities and is primarily intended for use within test assertions rather +## than production code. +class_name GdUnitTuple +extends RefCounted + +var _values: Array = [] + + +## Initializes a new GdUnitTuple with test values. +## [br] +## Creates a tuple to hold multiple values extracted from test objects +## or expected values for assertions. Commonly used with the [code]tuple()[/code] +## helper function in GdUnit4 tests. +## [br] +## [b]Parameters:[/b] +## - [code]...args[/code]: Variable number of values to store. +func _init(...args: Array) -> void: + _values = args + + +## Returns the tuple's values as an array for assertion comparisons. +## [br] +## Provides access to the stored test values. Used internally by GdUnit4's +## assertion system when comparing tuples in test validations. +## [br] +## [b]Returns:[/b] +## An [Array] containing all values stored in the tuple. +func values() -> Array: + return _values + + +## Returns a string representation for test output and debugging. +## [br] +## Formats the tuple for display in test results, error messages, and debug logs. +## This method is automatically called by GdUnit4 when displaying assertion +## failures involving tuples. +## [br] +## [b]Returns:[/b] +## A [String] in the format "tuple([value1, value2, ...])" +func _to_string() -> String: + return "tuple(%s)" % str(_values) diff --git a/addons/gdUnit4/src/GdUnitTuple.gd.uid b/addons/gdUnit4/src/GdUnitTuple.gd.uid new file mode 100644 index 0000000..baa87d4 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitTuple.gd.uid @@ -0,0 +1 @@ +uid://btxdkihimm6t2 diff --git a/addons/gdUnit4/src/GdUnitValueExtractor.gd b/addons/gdUnit4/src/GdUnitValueExtractor.gd new file mode 100644 index 0000000..1a34445 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitValueExtractor.gd @@ -0,0 +1,9 @@ +## This is the base interface for value extraction +class_name GdUnitValueExtractor +extends RefCounted + + +## Extracts a value by given implementation +func extract_value(value :Variant) -> Variant: + push_error("Uninplemented func 'extract_value'") + return value diff --git a/addons/gdUnit4/src/GdUnitValueExtractor.gd.uid b/addons/gdUnit4/src/GdUnitValueExtractor.gd.uid new file mode 100644 index 0000000..c934a8f --- /dev/null +++ b/addons/gdUnit4/src/GdUnitValueExtractor.gd.uid @@ -0,0 +1 @@ +uid://be6abg0ghp37l diff --git a/addons/gdUnit4/src/GdUnitVectorAssert.gd b/addons/gdUnit4/src/GdUnitVectorAssert.gd new file mode 100644 index 0000000..c186cba --- /dev/null +++ b/addons/gdUnit4/src/GdUnitVectorAssert.gd @@ -0,0 +1,55 @@ +## An Assertion Tool to verify Vector values +@abstract class_name GdUnitVectorAssert +extends GdUnitAssert + + +## Verifies that the current value is null. +@abstract func is_null() -> GdUnitVectorAssert + + +## Verifies that the current value is not null. +@abstract func is_not_null() -> GdUnitVectorAssert + + +## Verifies that the current value is equal to the given one. +@abstract func is_equal(expected: Variant) -> GdUnitVectorAssert + + +## Verifies that the current value is not equal to expected one. +@abstract func is_not_equal(expected: Variant) -> GdUnitVectorAssert + + +## Verifies that the current and expected value are approximately equal. +@abstract func is_equal_approx(expected: Variant, approx: Variant) -> GdUnitVectorAssert + + +## Overrides the default failure message by given custom message. +@abstract func override_failure_message(message: String) -> GdUnitVectorAssert + + +## Appends a custom message to the failure message. +@abstract func append_failure_message(message: String) -> GdUnitVectorAssert + + +## Verifies that the current value is less than the given one. +@abstract func is_less(expected: Variant) -> GdUnitVectorAssert + + +## Verifies that the current value is less than or equal the given one. +@abstract func is_less_equal(expected: Variant) -> GdUnitVectorAssert + + +## Verifies that the current value is greater than the given one. +@abstract func is_greater(expected: Variant) -> GdUnitVectorAssert + + +## Verifies that the current value is greater than or equal the given one. +@abstract func is_greater_equal(expected: Variant) -> GdUnitVectorAssert + + +## Verifies that the current value is between the given boundaries (inclusive). +@abstract func is_between(from: Variant, to: Variant) -> GdUnitVectorAssert + + +## Verifies that the current value is not between the given boundaries (inclusive). +@abstract func is_not_between(from: Variant, to: Variant) -> GdUnitVectorAssert diff --git a/addons/gdUnit4/src/GdUnitVectorAssert.gd.uid b/addons/gdUnit4/src/GdUnitVectorAssert.gd.uid new file mode 100644 index 0000000..bde28f5 --- /dev/null +++ b/addons/gdUnit4/src/GdUnitVectorAssert.gd.uid @@ -0,0 +1 @@ +uid://bchhqrm7gxygm diff --git a/addons/gdUnit4/src/asserts/CallBackValueProvider.gd b/addons/gdUnit4/src/asserts/CallBackValueProvider.gd new file mode 100644 index 0000000..6be4b3e --- /dev/null +++ b/addons/gdUnit4/src/asserts/CallBackValueProvider.gd @@ -0,0 +1,25 @@ +# a value provider unsing a callback to get `next` value from a certain function +class_name CallBackValueProvider +extends ValueProvider + +var _cb :Callable +var _args :Array + + +func _init(instance :Object, func_name :String, args :Array = Array(), force_error := true) -> void: + _cb = Callable(instance, func_name); + _args = args + if force_error and not _cb.is_valid(): + push_error("Can't find function '%s' checked instance %s" % [func_name, instance]) + + +func get_value() -> Variant: + if not _cb.is_valid(): + return null + if _args.is_empty(): + return await _cb.call() + return await _cb.callv(_args) + + +func dispose() -> void: + _cb = Callable() diff --git a/addons/gdUnit4/src/asserts/CallBackValueProvider.gd.uid b/addons/gdUnit4/src/asserts/CallBackValueProvider.gd.uid new file mode 100644 index 0000000..4813c68 --- /dev/null +++ b/addons/gdUnit4/src/asserts/CallBackValueProvider.gd.uid @@ -0,0 +1 @@ +uid://bauwyi0k0fetp diff --git a/addons/gdUnit4/src/asserts/DefaultValueProvider.gd b/addons/gdUnit4/src/asserts/DefaultValueProvider.gd new file mode 100644 index 0000000..2f828fa --- /dev/null +++ b/addons/gdUnit4/src/asserts/DefaultValueProvider.gd @@ -0,0 +1,13 @@ +# default value provider, simple returns the initial value +class_name DefaultValueProvider +extends ValueProvider + +var _value: Variant + + +func _init(value: Variant) -> void: + _value = value + + +func get_value() -> Variant: + return _value diff --git a/addons/gdUnit4/src/asserts/DefaultValueProvider.gd.uid b/addons/gdUnit4/src/asserts/DefaultValueProvider.gd.uid new file mode 100644 index 0000000..277a58e --- /dev/null +++ b/addons/gdUnit4/src/asserts/DefaultValueProvider.gd.uid @@ -0,0 +1 @@ +uid://b24r327dm6o6m diff --git a/addons/gdUnit4/src/asserts/GdAssertMessages.gd b/addons/gdUnit4/src/asserts/GdAssertMessages.gd new file mode 100644 index 0000000..93f9eb6 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdAssertMessages.gd @@ -0,0 +1,757 @@ +class_name GdAssertMessages +extends Resource + + +const SUB_COLOR := Color(1, 0, 0, .15) +const ADD_COLOR := Color(0, 1, 0, .15) + +const NO_ORPHAN_DETAILS := "⚠️No details available. Run tests in debug mode to collect details." + +# Dictionary of control characters and their readable representations +const CONTROL_CHARS = { + "\n": "", # Line Feed + "\r": "", # Carriage Return + "\t": "", # Tab + "\b": "", # Backspace + "\f": "", # Form Feed + "\v": "", # Vertical Tab + "\a": "", # Bell + "": "" # Escape +} + + +static var _warn_color := GdUnitEditorColorTheme.state_warning.to_html() +static var _error_color := GdUnitEditorColorTheme.state_failure.to_html() +static var _value_color := GdUnitEditorColorTheme.value_color.to_html() + + +static func format_dict(value :Variant) -> String: + if not value is Dictionary: + return str(value) + + var dict_value: Dictionary = value + if dict_value.is_empty(): + return "{ }" + var as_rows := var_to_str(value).split("\n") + for index in range( 1, as_rows.size()-1): + as_rows[index] = " " + as_rows[index] + as_rows[-1] = " " + as_rows[-1] + return "\n".join(as_rows) + + +# improved version of InputEvent as text +static func input_event_as_text(event :InputEvent) -> String: + var text := "" + if event is InputEventKey: + var key_event := event as InputEventKey + text += """ + InputEventKey : keycode=%s (%s) pressed: %s + physical_keycode: %s + location: %s + echo: %s""" % [ + key_event.keycode, + key_event.as_text_keycode(), + key_event.pressed, + key_event.physical_keycode, + key_event.location, + key_event.echo] + else: + text += event.as_text() + if event is InputEventMouse: + var mouse_event := event as InputEventMouse + text += """ + global_position: %s""" % mouse_event.global_position + if event is InputEventWithModifiers: + var mouse_event := event as InputEventWithModifiers + text += """ + -------- + mods: %s + shift: %s + alt: %s + control: %s + meta: %s + command: %s""" % [ + mouse_event.get_modifiers_mask(), + mouse_event.shift_pressed, + mouse_event.alt_pressed, + mouse_event.ctrl_pressed, + mouse_event.meta_pressed, + mouse_event.command_or_control_autoremap] + return text.dedent() + + +static func _colored_string_div(characters: String) -> String: + return colored_array_div(characters.to_utf32_buffer().to_int32_array()) + + +static func colored_array_div(characters: PackedInt32Array) -> String: + if characters.is_empty(): + return "" + var result := PackedInt32Array() + var index := 0 + var missing_chars := PackedInt32Array() + var additional_chars := PackedInt32Array() + + while index < characters.size(): + var character := characters[index] + match character: + GdDiffTool.DIV_ADD: + index += 1 + @warning_ignore("return_value_discarded") + additional_chars.append(characters[index]) + GdDiffTool.DIV_SUB: + index += 1 + @warning_ignore("return_value_discarded") + missing_chars.append(characters[index]) + _: + if not missing_chars.is_empty(): + result.append_array(format_chars(missing_chars, SUB_COLOR)) + missing_chars = PackedInt32Array() + if not additional_chars.is_empty(): + result.append_array(format_chars(additional_chars, ADD_COLOR)) + additional_chars = PackedInt32Array() + @warning_ignore("return_value_discarded") + result.append(character) + index += 1 + + result.append_array(format_chars(missing_chars, SUB_COLOR)) + result.append_array(format_chars(additional_chars, ADD_COLOR)) + return result.to_byte_array().get_string_from_utf32() + + +static func _typed_value(value :Variant) -> String: + return GdDefaultValueDecoder.decode(value) + + +static func _warning(error :String) -> String: + return "[color=%s]%s[/color]" % [_warn_color, error] + + +static func _error(error :String) -> String: + return "[color=%s]%s[/color]" % [_error_color, error] + + +static func _nerror(number :Variant) -> String: + match typeof(number): + TYPE_INT: + return "[color=%s]%d[/color]" % [_error_color, number] + TYPE_FLOAT: + return "[color=%s]%f[/color]" % [_error_color, number] + _: + return "[color=%s]%s[/color]" % [_error_color, str(number)] + + +static func _colored(value: Variant, color: Color) -> String: + return "[color=%s]%s[/color]" % [color.to_html(), value] + + +static func _colored_value(value :Variant) -> String: + match typeof(value): + TYPE_STRING, TYPE_STRING_NAME: + return "'[color=%s]%s[/color]'" % [_value_color, _colored_string_div(str(value))] + TYPE_INT: + return "[color=%s]%d[/color]" % [_value_color, value] + TYPE_FLOAT: + return "[color=%s]%s[/color]" % [_value_color, _typed_value(value)] + TYPE_COLOR: + return "'[color=%s]%s[/color]'" % [_value_color, _typed_value(value)] + TYPE_OBJECT: + if value == null: + return "'[color=%s][/color]'" % [_value_color] + if value is InputEvent: + var ie: InputEvent = value + return "[color=%s]<%s>[/color]" % [_value_color, input_event_as_text(ie)] + var obj_value: Object = value + if obj_value.has_method("_to_string"): + return "[color=%s]<%s>[/color]" % [_value_color, str(value)] + return "[color=%s]<%s>[/color]" % [_value_color, obj_value.get_class()] + TYPE_DICTIONARY: + return "'[color=%s]%s[/color]'" % [_value_color, format_dict(value)] + _: + if GdArrayTools.is_array_type(value): + return "'[color=%s]%s[/color]'" % [_value_color, _typed_value(value)] + return "'[color=%s]%s[/color]'" % [_value_color, value] + + + +static func _index_report_as_table(index_reports :Array) -> String: + var table := "[table=3]$cells[/table]" + var header := "[cell][right][b]$text[/b][/right]\t[/cell]" + var cell := "[cell][right]$text[/right]\t[/cell]" + var cells := header.replace("$text", "Index") + header.replace("$text", "Current") + header.replace("$text", "Expected") + for report :Variant in index_reports: + var index :String = str(report["index"]) + var current :String = str(report["current"]) + var expected :String = str(report["expected"]) + cells += cell.replace("$text", index) + cell.replace("$text", current) + cell.replace("$text", expected) + return table.replace("$cells", cells) + + +static func orphan_warning(orphans_count: int, info: String) -> String: + if EngineDebugger.is_active(): + return """ + %s Detected %s possible orphan nodes. + To capture detailed information insert + [code]%s[/code] + """.dedent().trim_prefix("\n").trim_suffix("\n") % [ + _warning("WARNING:"), + _colored_value(orphans_count), + _colored(info, Color.STEEL_BLUE) + ] + return """ + %s Detected %s possible orphan nodes. + %s + """.dedent().trim_prefix("\n").trim_suffix("\n") % [ + _warning("WARNING:"), + _colored_value(orphans_count), + _warning(NO_ORPHAN_DETAILS) + ] + + +static func orphan_detected(orphan_count: int) -> String: + if EngineDebugger.is_active(): + return "%s Detected %s orphan nodes." % [ + _warning("WARNING:"), + _colored_value(orphan_count) + ] + return """ + %s Detected %s orphan nodes. + %s""".dedent().trim_prefix("\n") % [ + _warning("WARNING:"), + _colored_value(orphan_count), + _warning(NO_ORPHAN_DETAILS) + ] + + +static func orphan_node_info(orphan_info: GdUnitOrphanNodeInfo) -> String: + var message := "<%s> Id:%s" % [ + _colored(orphan_info._type, GdUnitEditorColorTheme.engine_type_color), + _colored(orphan_info._id, GdUnitEditorColorTheme.engine_type_color) + ] + if orphan_info._stack_element == null: + message += _warning("\n\tNo source info available") + return message + + +static func fuzzer_interuped(iterations: int, error: String) -> String: + return "%s %s %s\n %s" % [ + _error("Found an error after"), + _colored_value(iterations + 1), + _error("test iterations"), + error] + + +static func test_timeout(timeout :int) -> String: + return "%s\n %s" % [_error("Timeout !"), _colored_value("Test timed out after %s" % LocalTime.elapsed(timeout))] + + +static func test_session_terminated() -> String: + return "%s" % _error("Test Session Terminated") + + +# gdlint:disable = mixed-tabs-and-spaces +static func test_suite_skipped(hint :String, skip_count :int) -> String: + return """ + %s + Skipped %s tests + Reason: %s + """.dedent().trim_prefix("\n")\ + % [_error("The Entire test-suite is skipped!"), _colored_value(skip_count), _colored_value(hint)] + + +static func test_skipped(hint :String) -> String: + return """ + %s + Reason: %s + """.dedent().trim_prefix("\n")\ + % [_error("This test is skipped!"), _colored_value(hint)] + + +static func error_not_implemented() -> String: + return _error("Test not implemented!") + + +static func error_with_message(message: String) -> String: + return _error(message) + + +static func error_is_null(current :Variant) -> String: + return "%s %s but was %s" % [_error("Expecting:"), _colored_value(null), _colored_value(current)] + + +static func error_is_not_null() -> String: + return "%s %s" % [_error("Expecting: not to be"), _colored_value(null)] + + +static func error_equal(current :Variant, expected :Variant, index_reports :Array = []) -> String: + var report := """ + %s + %s + but was + %s""".dedent().trim_prefix("\n") % [_error("Expecting:"), _colored_value(expected), _colored_value(current)] + if not index_reports.is_empty(): + report += "\n\n%s\n%s" % [_error("Differences found:"), _index_report_as_table(index_reports)] + return report + + +static func error_not_equal(current :Variant, expected :Variant) -> String: + return "%s\n %s\n not equal to\n %s" % [_error("Expecting:"), _colored_value(expected), _colored_value(current)] + + +static func error_not_equal_case_insensetiv(current :Variant, expected :Variant) -> String: + return "%s\n %s\n not equal to (case insensitiv)\n %s" % [ + _error("Expecting:"), _colored_value(expected), _colored_value(current)] + + +static func error_is_empty(current :Variant) -> String: + return "%s\n must be empty but was\n %s" % [_error("Expecting:"), _colored_value(current)] + + +static func error_is_not_empty() -> String: + return "%s\n must not be empty" % [_error("Expecting:")] + + +static func error_is_same(current :Variant, expected :Variant) -> String: + return "%s\n %s\n to refer to the same object\n %s" % [_error("Expecting:"), _colored_value(expected), _colored_value(current)] + + +@warning_ignore("unused_parameter") +static func error_not_same(_current :Variant, expected :Variant) -> String: + return "%s\n %s" % [_error("Expecting not same:"), _colored_value(expected)] + + +static func error_not_same_error(current :Variant, expected :Variant) -> String: + return "%s\n %s\n but was\n %s" % [_error("Expecting error message:"), _colored_value(expected), _colored_value(current)] + + +static func error_is_instanceof(current: GdUnitResult, expected :GdUnitResult) -> String: + return "%s\n %s\n But it was %s" % [_error("Expected instance of:"),\ + _colored_value(expected.or_else(null)), _colored_value(current.or_else(null))] + + +# -- Boolean Assert specific messages ----------------------------------------------------- +static func error_is_true(current :Variant) -> String: + return "%s %s but is %s" % [_error("Expecting:"), _colored_value(true), _colored_value(current)] + + +static func error_is_false(current :Variant) -> String: + return "%s %s but is %s" % [_error("Expecting:"), _colored_value(false), _colored_value(current)] + + +# - Integer/Float Assert specific messages ----------------------------------------------------- + +static func error_is_even(current :Variant) -> String: + return "%s\n %s must be even" % [_error("Expecting:"), _colored_value(current)] + + +static func error_is_odd(current :Variant) -> String: + return "%s\n %s must be odd" % [_error("Expecting:"), _colored_value(current)] + + +static func error_is_negative(current :Variant) -> String: + return "%s\n %s be negative" % [_error("Expecting:"), _colored_value(current)] + + +static func error_is_not_negative(current :Variant) -> String: + return "%s\n %s be not negative" % [_error("Expecting:"), _colored_value(current)] + + +static func error_is_zero(current :Variant) -> String: + return "%s\n equal to 0 but is %s" % [_error("Expecting:"), _colored_value(current)] + + +static func error_is_not_zero() -> String: + return "%s\n not equal to 0" % [_error("Expecting:")] + + +static func error_is_wrong_type(current_type :Variant.Type, expected_type :Variant.Type) -> String: + return "%s\n Expecting type %s but is %s" % [ + _error("Unexpected type comparison:"), + _colored_value(GdObjects.type_as_string(current_type)), + _colored_value(GdObjects.type_as_string(expected_type))] + + +static func error_is_value(operation :int, current :Variant, expected :Variant, expected2 :Variant = null) -> String: + match operation: + Comparator.EQUAL: + return "%s\n %s but was %s" % [_error("Expecting:"), _colored_value(expected), _colored_value(current)] + Comparator.LESS_THAN: + return "%s\n %s but was %s" % [_error("Expecting to be less than:"), _colored_value(expected), _colored_value(current)] + Comparator.LESS_EQUAL: + return "%s\n %s but was %s" % [_error("Expecting to be less than or equal:"), _colored_value(expected), _colored_value(current)] + Comparator.GREATER_THAN: + return "%s\n %s but was %s" % [_error("Expecting to be greater than:"), _colored_value(expected), _colored_value(current)] + Comparator.GREATER_EQUAL: + return "%s\n %s but was %s" % [_error("Expecting to be greater than or equal:"), _colored_value(expected), _colored_value(current)] + Comparator.BETWEEN_EQUAL: + return "%s\n %s\n in range between\n %s <> %s" % [ + _error("Expecting:"), _colored_value(current), _colored_value(expected), _colored_value(expected2)] + Comparator.NOT_BETWEEN_EQUAL: + return "%s\n %s\n not in range between\n %s <> %s" % [ + _error("Expecting:"), _colored_value(current), _colored_value(expected), _colored_value(expected2)] + return "TODO create expected message" + + +static func error_is_in(current :Variant, expected :Array) -> String: + return "%s\n %s\n is in\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(str(expected))] + + +static func error_is_not_in(current :Variant, expected :Array) -> String: + return "%s\n %s\n is not in\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(str(expected))] + + +# - StringAssert --------------------------------------------------------------------------------- +static func error_equal_ignoring_case(current :Variant, expected :Variant) -> String: + return "%s\n %s\n but was\n %s (ignoring case)" % [_error("Expecting:"), _colored_value(expected), _colored_value(current)] + + +static func error_contains(current :Variant, expected :Variant) -> String: + return "%s\n %s\n do contains\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)] + + +static func error_not_contains(current :Variant, expected :Variant) -> String: + return "%s\n %s\n not do contain\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)] + + +static func error_contains_ignoring_case(current :Variant, expected :Variant) -> String: + return "%s\n %s\n contains\n %s\n (ignoring case)" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)] + + +static func error_not_contains_ignoring_case(current :Variant, expected :Variant) -> String: + return "%s\n %s\n not do contains\n %s\n (ignoring case)" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)] + + +static func error_starts_with(current :Variant, expected :Variant) -> String: + return "%s\n %s\n to start with\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)] + + +static func error_ends_with(current :Variant, expected :Variant) -> String: + return "%s\n %s\n to end with\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)] + + +static func error_has_length(current :Variant, expected: int, compare_operator :int) -> String: + @warning_ignore("unsafe_method_access") + var current_length :Variant = current.length() if current != null else null + match compare_operator: + Comparator.EQUAL: + return "%s\n %s but was %s in\n %s" % [ + _error("Expecting size:"), _colored_value(expected), _nerror(current_length), _colored_value(current)] + Comparator.LESS_THAN: + return "%s\n %s but was %s in\n %s" % [ + _error("Expecting size to be less than:"), _colored_value(expected), _nerror(current_length), _colored_value(current)] + Comparator.LESS_EQUAL: + return "%s\n %s but was %s in\n %s" % [ + _error("Expecting size to be less than or equal:"), _colored_value(expected), + _nerror(current_length), _colored_value(current)] + Comparator.GREATER_THAN: + return "%s\n %s but was %s in\n %s" % [ + _error("Expecting size to be greater than:"), _colored_value(expected), + _nerror(current_length), _colored_value(current)] + Comparator.GREATER_EQUAL: + return "%s\n %s but was %s in\n %s" % [ + _error("Expecting size to be greater than or equal:"), _colored_value(expected), + _nerror(current_length), _colored_value(current)] + return "TODO create expected message" + + +# - ArrayAssert specific messgaes --------------------------------------------------- + +static func error_arr_contains(current: Variant, expected: Variant, not_expect: Variant, not_found: Variant, by_reference: bool) -> String: + var failure_message := "Expecting contains SAME elements:" if by_reference else "Expecting contains elements:" + var error := "%s\n %s\n do contains (in any order)\n %s" % [ + _error(failure_message), _colored_value(current), _colored_value(expected)] + if not is_empty(not_expect): + error += "\nbut some elements where not expected:\n %s" % _colored_value(not_expect) + if not is_empty(not_found): + var prefix := "but" if is_empty(not_expect) else "and" + error += "\n%s could not find elements:\n %s" % [prefix, _colored_value(not_found)] + return error + + +static func error_arr_contains_exactly( + current: Variant, + expected: Variant, + not_expect: Variant, + not_found: Variant, compare_mode: GdObjects.COMPARE_MODE) -> String: + var failure_message := ( + "Expecting contains exactly elements:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST + else "Expecting contains SAME exactly elements:" + ) + if is_empty(not_expect) and is_empty(not_found): + var arr_current: Array = current + var arr_expected: Array = expected + var diff := _find_first_diff(arr_current, arr_expected) + return "%s\n %s\n do contains (in same order)\n %s\n but has different order %s" % [ + _error(failure_message), _colored_value(current), _colored_value(expected), diff] + + var error := "%s\n %s\n do contains (in same order)\n %s" % [ + _error(failure_message), _colored_value(current), _colored_value(expected)] + if not is_empty(not_expect): + error += "\nbut some elements where not expected:\n %s" % _colored_value(not_expect) + if not is_empty(not_found): + var prefix := "but" if is_empty(not_expect) else "and" + error += "\n%s could not find elements:\n %s" % [prefix, _colored_value(not_found)] + return error + + +static func error_arr_contains_exactly_in_any_order( + current: Variant, + expected: Variant, + not_expect: Variant, + not_found: Variant, + compare_mode: GdObjects.COMPARE_MODE) -> String: + + var failure_message := ( + "Expecting contains exactly elements:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST + else "Expecting contains SAME exactly elements:" + ) + var error := "%s\n %s\n do contains exactly (in any order)\n %s" % [ + _error(failure_message), _colored_value(current), _colored_value(expected)] + if not is_empty(not_expect): + error += "\nbut some elements where not expected:\n %s" % _colored_value(not_expect) + if not is_empty(not_found): + var prefix := "but" if is_empty(not_expect) else "and" + error += "\n%s could not find elements:\n %s" % [prefix, _colored_value(not_found)] + return error + + +static func error_arr_not_contains(current: Variant, expected: Variant, found: Variant, compare_mode: GdObjects.COMPARE_MODE) -> String: + var failure_message := "Expecting:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST else "Expecting SAME:" + var error := "%s\n %s\n do not contains\n %s" % [ + _error(failure_message), _colored_value(current), _colored_value(expected)] + if not is_empty(found): + error += "\n but found elements:\n %s" % _colored_value(found) + return error + + +# - DictionaryAssert specific messages ---------------------------------------------- +static func error_contains_keys(current :Array, expected :Array, keys_not_found :Array, compare_mode :GdObjects.COMPARE_MODE) -> String: + var failure := ( + "Expecting contains keys:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST + else "Expecting contains SAME keys:" + ) + return "%s\n %s\n to contains:\n %s\n but can't find key's:\n %s" % [ + _error(failure), _colored_value(current), _colored_value(expected), _colored_value(keys_not_found)] + + +static func error_not_contains_keys(current :Array, expected :Array, keys_not_found :Array, compare_mode :GdObjects.COMPARE_MODE) -> String: + var failure := ( + "Expecting NOT contains keys:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST + else "Expecting NOT contains SAME keys" + ) + return "%s\n %s\n do not contains:\n %s\n but contains key's:\n %s" % [ + _error(failure), _colored_value(current), _colored_value(expected), _colored_value(keys_not_found)] + + +static func error_contains_key_value(key :Variant, value :Variant, current_value :Variant, compare_mode :GdObjects.COMPARE_MODE) -> String: + var failure := ( + "Expecting contains key and value:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST + else "Expecting contains SAME key and value:" + ) + return "%s\n %s : %s\n but contains\n %s : %s" % [ + _error(failure), _colored_value(key), _colored_value(value), _colored_value(key), _colored_value(current_value)] + + +# - ResultAssert specific errors ---------------------------------------------------- +static func error_result_is_empty(current :GdUnitResult) -> String: + return _result_error_message(current, GdUnitResult.EMPTY) + + +static func error_result_is_success(current :GdUnitResult) -> String: + return _result_error_message(current, GdUnitResult.SUCCESS) + + +static func error_result_is_warning(current :GdUnitResult) -> String: + return _result_error_message(current, GdUnitResult.WARN) + + +static func error_result_is_error(current :GdUnitResult) -> String: + return _result_error_message(current, GdUnitResult.ERROR) + + +static func error_result_has_message(current :String, expected :String) -> String: + return "%s\n %s\n but was\n %s." % [_error("Expecting:"), _colored_value(expected), _colored_value(current)] + + +static func error_result_has_message_on_success(expected :String) -> String: + return "%s\n %s\n but the GdUnitResult is a success." % [_error("Expecting:"), _colored_value(expected)] + + +static func error_result_is_value(current :Variant, expected :Variant) -> String: + return "%s\n %s\n but was\n %s." % [_error("Expecting to contain same value:"), _colored_value(expected), _colored_value(current)] + + +static func _result_error_message(current :GdUnitResult, expected_type :int) -> String: + if current == null: + return _error("Expecting the result must be a %s but was ." % result_type(expected_type)) + if current.is_success(): + return _error("Expecting the result must be a %s but was SUCCESS." % result_type(expected_type)) + var error := "Expecting the result must be a %s but was %s:" % [result_type(expected_type), result_type(current._state)] + return "%s\n %s" % [_error(error), _colored_value(result_message(current))] + + +static func error_interrupted(func_name :String, expected :Variant, elapsed :String) -> String: + func_name = humanized(func_name) + if expected == null: + return "%s %s but timed out after %s" % [_error("Expected:"), func_name, elapsed] + return "%s %s %s but timed out after %s" % [_error("Expected:"), func_name, _colored_value(expected), elapsed] + + +static func error_wait_signal(signal_name :String, args :Array, elapsed :String) -> String: + if args.is_empty(): + return "%s %s but timed out after %s" % [ + _error("Expecting emit signal:"), _colored_value(signal_name + "()"), elapsed] + return "%s %s but timed out after %s" % [ + _error("Expecting emit signal:"), _colored_value(signal_name + "(" + str(args) + ")"), elapsed] + + +static func error_signal_emitted(signal_name :String, args :Array, elapsed :String) -> String: + if args.is_empty(): + return "%s %s but is emitted after %s" % [ + _error("Expecting do not emit signal:"), _colored_value(signal_name + "()"), elapsed] + return "%s %s but is emitted after %s" % [ + _error("Expecting do not emit signal:"), _colored_value(signal_name + "(" + str(args) + ")"), elapsed] + + +static func error_await_signal_on_invalid_instance(source :Variant, signal_name :String, args :Array) -> String: + return "%s\n await_signal_on(%s, %s, %s)" % [ + _error("Invalid source! Can't await on signal:"), _colored_value(source), signal_name, args] + + +static func result_type(type :int) -> String: + match type: + GdUnitResult.SUCCESS: return "SUCCESS" + GdUnitResult.WARN: return "WARNING" + GdUnitResult.ERROR: return "ERROR" + GdUnitResult.EMPTY: return "EMPTY" + return "UNKNOWN" + + +static func result_message(result :GdUnitResult) -> String: + match result._state: + GdUnitResult.SUCCESS: return "" + GdUnitResult.WARN: return result.warn_message() + GdUnitResult.ERROR: return result.error_message() + GdUnitResult.EMPTY: return "" + return "UNKNOWN" +# ----------------------------------------------------------------------------------- + +# - Spy|Mock specific errors ---------------------------------------------------- +static func error_no_more_interactions(summary :Dictionary) -> String: + var interactions := PackedStringArray() + for args :Array in summary.keys(): + var times :int = summary[args] + @warning_ignore("return_value_discarded") + interactions.append(_format_arguments(args, times)) + return "%s\n%s\n%s" % [_error("Expecting no more interactions!"), _error("But found interactions on:"), "\n".join(interactions)] + + +static func error_validate_interactions(current_interactions: Dictionary, expected_interactions: Dictionary) -> String: + var collected_interactions := PackedStringArray() + for args: Array in current_interactions.keys(): + var times: int = current_interactions[args] + @warning_ignore("return_value_discarded") + collected_interactions.append(_format_arguments(args, times)) + + var arguments: Array = expected_interactions.keys()[0] + var interactions: int = expected_interactions.values()[0] + var expected_interaction := _format_arguments(arguments, interactions) + return "%s\n%s\n%s\n%s" % [ + _error("Expecting interaction on:"), expected_interaction, _error("But found interactions on:"), "\n".join(collected_interactions)] + + +static func _format_arguments(args :Array, times :int) -> String: + var fname :String = args[0] + var fargs := args.slice(1) as Array + var typed_args := _to_typed_args(fargs) + var fsignature := _colored_value("%s(%s)" % [fname, ", ".join(typed_args)]) + return " %s %d time's" % [fsignature, times] + + +static func _to_typed_args(args :Array) -> PackedStringArray: + var typed := PackedStringArray() + for arg :Variant in args: + @warning_ignore("return_value_discarded") + typed.append(_format_arg(arg) + " :" + GdObjects.type_as_string(typeof(arg))) + return typed + + +static func _format_arg(arg :Variant) -> String: + if arg is InputEvent: + var ie: InputEvent = arg + return input_event_as_text(ie) + return str(arg) + + +static func _find_first_diff(left :Array, right :Array) -> String: + for index in left.size(): + var l :Variant = left[index] + var r :Variant = "" if index >= right.size() else right[index] + if not GdObjects.equals(l, r): + return "at position %s\n '%s' vs '%s'" % [_colored_value(index), _typed_value(l), _typed_value(r)] + return "" + + +static func error_has_size(current :Variant, expected: int) -> String: + @warning_ignore("unsafe_method_access") + var current_size :Variant = null if current == null else current.size() + return "%s\n %s\n but was\n %s" % [_error("Expecting size:"), _colored_value(expected), _colored_value(current_size)] + + +static func error_contains_exactly(current: Array, expected: Array) -> String: + return "%s\n %s\n but was\n %s" % [_error("Expecting exactly equal:"), _colored_value(expected), _colored_value(current)] + + +static func format_chars(characters: PackedInt32Array, type: Color) -> PackedInt32Array: + if characters.size() == 0:# or characters[0] == 10: + return characters + + # Replace each control character with its readable form + var formatted_text := characters.to_byte_array().get_string_from_utf32() + for control_char: String in CONTROL_CHARS: + var replace_text: String = CONTROL_CHARS[control_char] + formatted_text = formatted_text.replace(control_char, replace_text) + + # Handle special ASCII control characters (0x00-0x1F, 0x7F) + var ascii_text := "" + for i in formatted_text.length(): + var character := formatted_text[i] + var code := character.unicode_at(0) + if code < 0x20 and not CONTROL_CHARS.has(character): # Control characters not handled above + ascii_text += "<0x%02X>" % code + elif code == 0x7F: # DEL character + ascii_text += "" + else: + ascii_text += character + + var message := "[bgcolor=#%s][color=white]%s[/color][/bgcolor]" % [ + type.to_html(), + ascii_text + ] + + var result := PackedInt32Array() + result.append_array(message.to_utf32_buffer().to_int32_array()) + return result + + +static func format_invalid(value :String) -> String: + return "[bgcolor=#%s][color=with]%s[/color][/bgcolor]" % [SUB_COLOR.to_html(), value] + + +static func humanized(value :String) -> String: + return value.replace("_", " ") + + +static func build_failure_message(failure :String, additional_failure_message: String, custom_failure_message: String) -> String: + var message := failure if custom_failure_message.is_empty() else custom_failure_message + if additional_failure_message.is_empty(): + return message + return """ + %s + [color=LIME_GREEN][b]Additional info:[/b][/color] + %s""".dedent().trim_prefix("\n") % [message, additional_failure_message] + + +static func is_empty(value: Variant) -> bool: + var arry_value: Array = value + return arry_value != null and arry_value.is_empty() diff --git a/addons/gdUnit4/src/asserts/GdAssertMessages.gd.uid b/addons/gdUnit4/src/asserts/GdAssertMessages.gd.uid new file mode 100644 index 0000000..5797e4f --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdAssertMessages.gd.uid @@ -0,0 +1 @@ +uid://cdmds6thunddc diff --git a/addons/gdUnit4/src/asserts/GdAssertReports.gd b/addons/gdUnit4/src/asserts/GdAssertReports.gd new file mode 100644 index 0000000..11c85db --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdAssertReports.gd @@ -0,0 +1,57 @@ +class_name GdAssertReports +extends RefCounted + +const LAST_ERROR = "last_assert_error_message" +const LAST_ERROR_LINE = "last_assert_error_line" + + +static func report_success() -> void: + GdUnitSignals.instance().gdunit_set_test_failed.emit(false) + GdAssertReports.set_last_error_line_number(-1) + Engine.remove_meta(LAST_ERROR) + + +static func report_warning(message :String, line_number :int) -> void: + GdUnitSignals.instance().gdunit_set_test_failed.emit(false) + send_report(GdUnitReport.new().create(GdUnitReport.WARN, line_number, message)) + + +static func report_error(error: GdUnitError) -> void: + GdUnitSignals.instance().gdunit_set_test_failed.emit(true) + GdAssertReports.set_last_error_line_number(error._line_number) + Engine.set_meta(LAST_ERROR, error._message) + Engine.set_meta("GD_TEST_FAILURE", true) + GdUnitThreadManager.get_current_context().get_execution_context().set_error(error) + + # if we expect to fail we handle as success test + if _do_expect_assert_failing(): + return + send_report(GdUnitReport.new().from_error(GdUnitReport.FAILURE, error)) + + +static func reset_last_error_line_number() -> void: + Engine.remove_meta(LAST_ERROR_LINE) + + +static func set_last_error_line_number(line_number :int) -> void: + Engine.set_meta(LAST_ERROR_LINE, line_number) + + +static func get_last_error_line_number() -> int: + if Engine.has_meta(LAST_ERROR_LINE): + return Engine.get_meta(LAST_ERROR_LINE) + return -1 + + +static func _do_expect_assert_failing() -> bool: + if Engine.has_meta(GdUnitConstants.EXPECT_ASSERT_REPORT_FAILURES): + return Engine.get_meta(GdUnitConstants.EXPECT_ASSERT_REPORT_FAILURES) + return false + + +static func current_failure() -> String: + return Engine.get_meta(LAST_ERROR) + + +static func send_report(report :GdUnitReport) -> void: + GdUnitThreadManager.get_current_context().get_execution_context().add_report(report) diff --git a/addons/gdUnit4/src/asserts/GdAssertReports.gd.uid b/addons/gdUnit4/src/asserts/GdAssertReports.gd.uid new file mode 100644 index 0000000..f64c26c --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdAssertReports.gd.uid @@ -0,0 +1 @@ +uid://d2yux5f864g6e diff --git a/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd new file mode 100644 index 0000000..ac312b7 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd @@ -0,0 +1,420 @@ +class_name GdUnitArrayAssertImpl +extends GdUnitArrayAssert + + +var _base: GdUnitAssertImpl +var _current_value_provider: ValueProvider +var _type_check: bool + + +func _init(current: Variant, type_check := true) -> void: + _type_check = type_check + _current_value_provider = DefaultValueProvider.new(current) + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + if not _validate_value_type(current): + @warning_ignore("return_value_discarded") + report_error("GdUnitArrayAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current)) + + +func _notification(event: int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func report_success() -> GdUnitArrayAssert: + @warning_ignore("return_value_discarded") + _base.report_success() + return self + + +func report_error(error: String) -> GdUnitArrayAssert: + @warning_ignore("return_value_discarded") + _base.report_error(error) + return self + + +func override_failure_message(message: String) -> GdUnitArrayAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message: String) -> GdUnitArrayAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func _validate_value_type(value: Variant) -> bool: + return value == null or GdArrayTools.is_array_type(value) + + +func get_current_value() -> Variant: + return _current_value_provider.get_value() + + +func max_length(left: Variant, right: Variant) -> int: + var ls := str(left).length() + var rs := str(right).length() + return rs if ls < rs else ls + + +# gdlint: disable=function-name +func _toPackedStringArray(value: Variant) -> PackedStringArray: + if GdArrayTools.is_array_type(value): + @warning_ignore("unsafe_cast") + return PackedStringArray(value as Array) + return PackedStringArray([str(value)]) + + +func _array_equals_div(current: Variant, expected: Variant, case_sensitive: bool = true) -> Array: + var current_value := _toPackedStringArray(current) + var expected_value := _toPackedStringArray(expected) + var index_report := Array() + for index in current_value.size(): + var c := current_value[index] + if index < expected_value.size(): + var e := expected_value[index] + if not GdObjects.equals(c, e, case_sensitive): + var length := max_length(c, e) + current_value[index] = GdAssertMessages.format_invalid(c.lpad(length)) + expected_value[index] = e.lpad(length) + index_report.push_back({"index": index, "current": c, "expected": e}) + else: + current_value[index] = GdAssertMessages.format_invalid(c) + index_report.push_back({"index": index, "current": c, "expected": ""}) + + for index in range(current_value.size(), expected_value.size()): + var value := expected_value[index] + expected_value[index] = GdAssertMessages.format_invalid(value) + index_report.push_back({"index": index, "current": "", "expected": value}) + return [current_value, expected_value, index_report] + + +func _array_div(compare_mode: GdObjects.COMPARE_MODE, left: Array[Variant], right: Array[Variant], _same_order := false) -> Array[Variant]: + var not_expect := left.duplicate(true) + var not_found := right.duplicate(true) + for index_c in left.size(): + var c: Variant = left[index_c] + for index_e in right.size(): + var e: Variant = right[index_e] + if GdObjects.equals(c, e, true, compare_mode): + GdArrayTools.erase_value(not_expect, e) + GdArrayTools.erase_value(not_found, c) + break + return [not_expect, not_found] + + +func _contains(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert: + var by_reference := compare_mode == GdObjects.COMPARE_MODE.OBJECT_REFERENCE + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + + if current_value == null: + return report_error(GdAssertMessages.error_arr_contains(current_value, expected_value, [], expected_value, by_reference)) + @warning_ignore("unsafe_cast") + var diffs := _array_div(compare_mode, current_value as Array[Variant], expected_value as Array[Variant]) + #var not_expect := diffs[0] as Array + var not_found: Array = diffs[1] + if not not_found.is_empty(): + return report_error(GdAssertMessages.error_arr_contains(current_value, expected_value, [], not_found, by_reference)) + return report_success() + + +func _contains_exactly(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + + if current_value == null: + return report_error(GdAssertMessages.error_arr_contains_exactly(null, expected_value, [], expected_value, compare_mode)) + # has same content in same order + if _is_equal(current_value, expected_value, true, compare_mode): + return report_success() + # check has same elements but in different order + if _is_equals_sorted(current_value, expected_value, true, compare_mode): + return report_error(GdAssertMessages.error_arr_contains_exactly(current_value, expected_value, [], [], compare_mode)) + # find the difference + @warning_ignore("unsafe_cast") + var diffs := _array_div(compare_mode, + current_value as Array[Variant], + expected_value as Array[Variant], + GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + var not_expect: Array[Variant] = diffs[0] + var not_found: Array[Variant] = diffs[1] + return report_error(GdAssertMessages.error_arr_contains_exactly(current_value, expected_value, not_expect, not_found, compare_mode)) + + +func _contains_exactly_in_any_order(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + + if current_value == null: + return report_error(GdAssertMessages.error_arr_contains_exactly_in_any_order(current_value, expected_value, [], + expected_value, compare_mode)) + # find the difference + @warning_ignore("unsafe_cast") + var diffs := _array_div(compare_mode, current_value as Array[Variant], expected_value as Array[Variant], false) + var not_expect: Array[Variant] = diffs[0] + var not_found: Array[Variant] = diffs[1] + if not_expect.is_empty() and not_found.is_empty(): + return report_success() + return report_error(GdAssertMessages.error_arr_contains_exactly_in_any_order(current_value, expected_value, not_expect, + not_found, compare_mode)) + + +func _not_contains(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + if current_value == null: + return report_error(GdAssertMessages.error_arr_contains_exactly_in_any_order(current_value, expected_value, [], + expected_value, compare_mode)) + @warning_ignore("unsafe_cast") + var diffs := _array_div(compare_mode, current_value as Array[Variant], expected_value as Array[Variant]) + var found: Array[Variant] = diffs[0] + @warning_ignore("unsafe_cast") + if found.size() == (current_value as Array).size(): + return report_success() + @warning_ignore("unsafe_cast") + var diffs2 := _array_div(compare_mode, expected_value as Array[Variant], diffs[1] as Array[Variant]) + return report_error(GdAssertMessages.error_arr_not_contains(current_value, expected_value, diffs2[0], compare_mode)) + + +func is_null() -> GdUnitArrayAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitArrayAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +func is_equal(...expected: Array) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant= _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + if current_value == null and expected_value != null: + return report_error(GdAssertMessages.error_equal(null, expected_value)) + + if not _is_equal(current_value, expected_value): + var diff := _array_equals_div(current_value, expected_value) + var expected_as_list := GdArrayTools.as_string(diff[0], false) + var current_as_list := GdArrayTools.as_string(diff[1], false) + var index_report: Array = diff[2] + return report_error(GdAssertMessages.error_equal(expected_as_list, current_as_list, index_report)) + return report_success() + + +# Verifies that the current Array is equal to the given one, ignoring case considerations. +func is_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + if current_value == null and expected_value != null: + @warning_ignore("unsafe_cast") + return report_error(GdAssertMessages.error_equal(null, GdArrayTools.as_string(expected_value))) + + if not _is_equal(current_value, expected_value, false): + @warning_ignore("unsafe_cast") + var diff := _array_equals_div(current_value, expected_value, true) + var expected_as_list := GdArrayTools.as_string(diff[0]) + var current_as_list := GdArrayTools.as_string(diff[1]) + var index_report: Array = diff[2] + return report_error(GdAssertMessages.error_equal(expected_as_list, current_as_list, index_report)) + return report_success() + + +func is_not_equal(...expected: Array) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + + if _is_equal(current_value, expected_value): + return report_error(GdAssertMessages.error_not_equal(current_value, expected_value)) + return report_success() + + +func is_not_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + var expected_value: Variant = _extract_variadic_value(expected) + if not _validate_value_type(expected_value): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value)) + + if _is_equal(current_value, expected_value, false): + @warning_ignore("unsafe_cast") + var c := GdArrayTools.as_string(current_value as Array) + @warning_ignore("unsafe_cast") + var e := GdArrayTools.as_string(expected_value) + return report_error(GdAssertMessages.error_not_equal_case_insensetiv(c, e)) + return report_success() + + +func is_empty() -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + @warning_ignore("unsafe_cast") + if current_value == null or (current_value as Array).size() > 0: + return report_error(GdAssertMessages.error_is_empty(current_value)) + return report_success() + + +func is_not_empty() -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + @warning_ignore("unsafe_cast") + if current_value != null and (current_value as Array).size() == 0: + return report_error(GdAssertMessages.error_is_not_empty()) + return report_success() + + +@warning_ignore("unused_parameter", "shadowed_global_identifier") +func is_same(expected: Variant) -> GdUnitArrayAssert: + if not _validate_value_type(expected): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected)) + var current: Variant = get_current_value() + if not is_same(current, expected): + @warning_ignore("return_value_discarded") + report_error(GdAssertMessages.error_is_same(current, expected)) + return self + + +func is_not_same(expected: Variant) -> GdUnitArrayAssert: + if not _validate_value_type(expected): + return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected)) + var current: Variant = get_current_value() + if is_same(current, expected): + @warning_ignore("return_value_discarded") + report_error(GdAssertMessages.error_not_same(current, expected)) + return self + + +func has_size(expected: int) -> GdUnitArrayAssert: + var current_value: Variant = get_current_value() + @warning_ignore("unsafe_cast") + if current_value == null or (current_value as Array).size() != expected: + return report_error(GdAssertMessages.error_has_size(current_value, expected)) + return report_success() + + +func contains(...expected: Array) -> GdUnitArrayAssert: + return _contains(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func contains_exactly(...expected: Array) -> GdUnitArrayAssert: + return _contains_exactly(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func contains_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert: + return _contains_exactly_in_any_order(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func contains_same(...expected: Array) -> GdUnitArrayAssert: + return _contains(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func contains_same_exactly(...expected: Array) -> GdUnitArrayAssert: + return _contains_exactly(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func contains_same_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert: + return _contains_exactly_in_any_order(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func not_contains(...expected: Array) -> GdUnitArrayAssert: + return _not_contains(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func not_contains_same(...expected: Array) -> GdUnitArrayAssert: + return _not_contains(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func is_instanceof(expected: Variant) -> GdUnitAssert: + @warning_ignore("unsafe_method_access") + _base.is_instanceof(expected) + return self + + +func extract(func_name: String, ...func_args: Array) -> GdUnitArrayAssert: + var extracted_elements := Array() + var args: Array = _extract_variadic_value(func_args) + var extractor := GdUnitFuncValueExtractor.new(func_name, args) + var current: Variant = get_current_value() + if current == null: + _current_value_provider = DefaultValueProvider.new(null) + else: + for element: Variant in current: + extracted_elements.append(extractor.extract_value(element)) + _current_value_provider = DefaultValueProvider.new(extracted_elements) + return self + + +func extractv(...extractors: Array) -> GdUnitArrayAssert: + var extracted_elements := Array() + var current: Variant = get_current_value() + if current == null: + _current_value_provider = DefaultValueProvider.new(null) + else: + for element: Variant in current: + var ev: Array[Variant] = [] + for index: int in extractors.size(): + var extractor: GdUnitValueExtractor = extractors[index] + ev.append(extractor.extract_value(element)) + if extractors.size() > 1: + extracted_elements.append(GdUnitTuple.new.callv(ev)) + else: + extracted_elements.append(ev[0]) + _current_value_provider = DefaultValueProvider.new(extracted_elements) + return self + + +## Small helper to support the old expected arguments as single array and variadic arguments +func _extract_variadic_value(values: Variant) -> Variant: + @warning_ignore("unsafe_method_access") + if values != null and values.size() == 1 and GdArrayTools.is_array_type(values[0]): + return values[0] + return values + + +@warning_ignore("incompatible_ternary") +func _is_equal( + left: Variant, + right: Variant, + case_sensitive := true, + compare_mode := GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool: + + @warning_ignore("unsafe_cast") + return GdObjects.equals( + (left as Array) if GdArrayTools.is_array_type(left) else left, + (right as Array) if GdArrayTools.is_array_type(right) else right, + case_sensitive, + compare_mode + ) + + +func _is_equals_sorted( + left: Variant, + right: Variant, + case_sensitive := true, + compare_mode := GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool: + + @warning_ignore("unsafe_cast") + return GdObjects.equals_sorted( + left as Array, + right as Array, + case_sensitive, + compare_mode) diff --git a/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd.uid new file mode 100644 index 0000000..9660108 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://0kg74xgfwp6k diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd new file mode 100644 index 0000000..5cd0f32 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd @@ -0,0 +1,78 @@ +class_name GdUnitAssertImpl +extends GdUnitAssert + + +var _current :Variant +var _custom_failure_message :String = "" +var _additional_failure_message: String = "" + + +func _init(current :Variant) -> void: + _current = current + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + GdAssertReports.reset_last_error_line_number() + + +func current_value() -> Variant: + return _current + + +func report_success() -> GdUnitAssert: + GdAssertReports.report_success() + return self + + +func report_error(failure :String, failure_line_number: int = -1) -> GdUnitAssert: + var stack_trace := GdUnitStackTrace.new() + var line_number := failure_line_number if failure_line_number != -1 else stack_trace.get_line_number() + GdAssertReports.set_last_error_line_number(line_number) + var failure_message := GdAssertMessages.build_failure_message(failure, _additional_failure_message, _custom_failure_message) + GdAssertReports.report_error(GdUnitError.new(failure_message, line_number, stack_trace)) + return self + + +func do_fail_not_implemented() -> GdUnitAssert: + return report_error(GdAssertMessages.error_not_implemented()) + + +func do_fail_with_message(message: String) -> GdUnitAssert: + return report_error(GdAssertMessages.error_with_message(message)) + + +func override_failure_message(message: String) -> GdUnitAssert: + _custom_failure_message = message + return self + + +func append_failure_message(message: String) -> GdUnitAssert: + _additional_failure_message = message + return self + + +func is_null() -> GdUnitAssert: + var current :Variant = current_value() + if current != null: + return report_error(GdAssertMessages.error_is_null(current)) + return report_success() + + +func is_not_null() -> GdUnitAssert: + var current :Variant = current_value() + if current == null: + return report_error(GdAssertMessages.error_is_not_null()) + return report_success() + + +func is_equal(expected: Variant) -> GdUnitAssert: + var current: Variant = current_value() + if not GdObjects.equals(current, expected): + return report_error(GdAssertMessages.error_equal(current, expected)) + return report_success() + + +func is_not_equal(expected: Variant) -> GdUnitAssert: + var current: Variant = current_value() + if GdObjects.equals(current, expected): + return report_error(GdAssertMessages.error_not_equal(current, expected)) + return report_success() diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd.uid new file mode 100644 index 0000000..9dd97af --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://bdq1ga71d44gr diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertions.gd b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd new file mode 100644 index 0000000..2c4bb1a --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd @@ -0,0 +1,39 @@ +# Preloads all GdUnit assertions +class_name GdUnitAssertions +extends RefCounted + + +@warning_ignore("return_value_discarded") +func _init() -> void: + # preload all gdunit assertions to speedup testsuite loading time + # gdlint:disable=private-method-call + @warning_ignore_start("return_value_discarded") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd") + GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd") + @warning_ignore_restore("return_value_discarded") + + +### We now load all used asserts and tool scripts into the cache according to the principle of "lazy loading" +### in order to noticeably reduce the loading time of the test suite. +# We go this hard way to increase the loading performance to avoid reparsing all the used scripts +# for more detailed info -> https://github.com/godotengine/godot/issues/67400 +# gdlint:disable=function-name +static func __lazy_load(script_path :String) -> GDScript: + return ResourceLoader.load(script_path, "GDScript", ResourceLoader.CACHE_MODE_REUSE) + + +static func validate_value_type(value :Variant, type :Variant.Type) -> bool: + return value == null or typeof(value) == type diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid new file mode 100644 index 0000000..9c1fa08 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid @@ -0,0 +1 @@ +uid://d0y7ocrqr5ebt diff --git a/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd new file mode 100644 index 0000000..e2c0c27 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd @@ -0,0 +1,83 @@ +extends GdUnitBoolAssert + +var _base: GdUnitAssertImpl + + +func _init(current :Variant) -> void: + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + if not GdUnitAssertions.validate_value_type(current, TYPE_BOOL): + @warning_ignore("return_value_discarded") + report_error("GdUnitBoolAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current)) + + +func _notification(event :int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func current_value() -> Variant: + return _base.current_value() + + +func report_success() -> GdUnitBoolAssert: + @warning_ignore("return_value_discarded") + _base.report_success() + return self + + +func report_error(error :String) -> GdUnitBoolAssert: + @warning_ignore("return_value_discarded") + _base.report_error(error) + return self + + +func override_failure_message(message: String) -> GdUnitBoolAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message: String) -> GdUnitBoolAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func is_null() -> GdUnitBoolAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitBoolAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +func is_equal(expected: Variant) -> GdUnitBoolAssert: + @warning_ignore("return_value_discarded") + _base.is_equal(expected) + return self + + +func is_not_equal(expected: Variant) -> GdUnitBoolAssert: + @warning_ignore("return_value_discarded") + _base.is_not_equal(expected) + return self + + +func is_true() -> GdUnitBoolAssert: + if current_value() != true: + return report_error(GdAssertMessages.error_is_true(current_value())) + return report_success() + + +func is_false() -> GdUnitBoolAssert: + if current_value() == true || current_value() == null: + return report_error(GdAssertMessages.error_is_false(current_value())) + return report_success() diff --git a/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd.uid new file mode 100644 index 0000000..19683ac --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://bas7jg54s1kep diff --git a/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd new file mode 100644 index 0000000..3013a95 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd @@ -0,0 +1,203 @@ +extends GdUnitDictionaryAssert + +var _base: GdUnitAssertImpl + + +func _init(current :Variant) -> void: + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + if not GdUnitAssertions.validate_value_type(current, TYPE_DICTIONARY): + @warning_ignore("return_value_discarded") + report_error("GdUnitDictionaryAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current)) + + +func _notification(event :int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func report_success() -> GdUnitDictionaryAssert: + @warning_ignore("return_value_discarded") + _base.report_success() + return self + + +func report_error(error :String) -> GdUnitDictionaryAssert: + @warning_ignore("return_value_discarded") + _base.report_error(error) + return self + + +func override_failure_message(message: String) -> GdUnitDictionaryAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message: String) -> GdUnitDictionaryAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func current_value() -> Variant: + return _base.current_value() + + +func is_null() -> GdUnitDictionaryAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitDictionaryAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +func is_equal(expected: Variant) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + if current == null: + return report_error(GdAssertMessages.error_equal(null, GdAssertMessages.format_dict(expected))) + if not GdObjects.equals(current, expected): + var c := GdAssertMessages.format_dict(current) + var e := GdAssertMessages.format_dict(expected) + return report_error(GdAssertMessages.error_equal(c, e)) + return report_success() + + +func is_not_equal(expected: Variant) -> GdUnitDictionaryAssert: + var current: Variant = current_value() + if GdObjects.equals(current, expected): + return report_error(GdAssertMessages.error_not_equal(current, expected)) + return report_success() + + +@warning_ignore("unused_parameter", "shadowed_global_identifier") +func is_same(expected :Variant) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + if current == null: + return report_error(GdAssertMessages.error_equal(null, GdAssertMessages.format_dict(expected))) + if not is_same(current, expected): + var c := GdAssertMessages.format_dict(current) + var e := GdAssertMessages.format_dict(expected) + return report_error(GdAssertMessages.error_is_same(c, e)) + return report_success() + + +@warning_ignore("unused_parameter", "shadowed_global_identifier") +func is_not_same(expected :Variant) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + if is_same(current, expected): + return report_error(GdAssertMessages.error_not_same(current, expected)) + return report_success() + + +func is_empty() -> GdUnitDictionaryAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current == null or not (current as Dictionary).is_empty(): + return report_error(GdAssertMessages.error_is_empty(current)) + return report_success() + + +func is_not_empty() -> GdUnitDictionaryAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current == null or (current as Dictionary).is_empty(): + return report_error(GdAssertMessages.error_is_not_empty()) + return report_success() + + +func has_size(expected: int) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + if current == null: + return report_error(GdAssertMessages.error_is_not_null()) + @warning_ignore("unsafe_cast") + if (current as Dictionary).size() != expected: + return report_error(GdAssertMessages.error_has_size(current, expected)) + return report_success() + + +func _contains_keys(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + var expected_value: Array = _extract_variadic_value(expected) + if current == null: + return report_error(GdAssertMessages.error_is_not_null()) + # find expected keys + @warning_ignore("unsafe_cast") + var keys_not_found :Array = expected_value.filter(_filter_by_key.bind((current as Dictionary).keys(), compare_mode)) + if not keys_not_found.is_empty(): + @warning_ignore("unsafe_cast") + return report_error(GdAssertMessages.error_contains_keys((current as Dictionary).keys() as Array, expected_value, + keys_not_found, compare_mode)) + return report_success() + + +func _contains_key_value(key :Variant, value :Variant, compare_mode :GdObjects.COMPARE_MODE) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + var expected := [key] + if current == null: + return report_error(GdAssertMessages.error_is_not_null()) + var dict_current: Dictionary = current + var keys_not_found :Array = expected.filter(_filter_by_key.bind(dict_current.keys(), compare_mode)) + if not keys_not_found.is_empty(): + return report_error(GdAssertMessages.error_contains_keys(dict_current.keys() as Array, expected, keys_not_found, compare_mode)) + if not GdObjects.equals(dict_current[key], value, true, compare_mode): + return report_error(GdAssertMessages.error_contains_key_value(key, value, dict_current[key], compare_mode)) + return report_success() + + +func _not_contains_keys(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitDictionaryAssert: + var current :Variant = current_value() + var expected_value: Array = _extract_variadic_value(expected) + if current == null: + return report_error(GdAssertMessages.error_is_not_null()) + var dict_current: Dictionary = current + var keys_found :Array = dict_current.keys().filter(_filter_by_key.bind(expected_value, compare_mode, true)) + if not keys_found.is_empty(): + return report_error(GdAssertMessages.error_not_contains_keys(dict_current.keys() as Array, expected_value, keys_found, compare_mode)) + return report_success() + + +func contains_keys(...expected: Array) -> GdUnitDictionaryAssert: + return _contains_keys(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func contains_key_value(key :Variant, value :Variant) -> GdUnitDictionaryAssert: + return _contains_key_value(key, value, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func not_contains_keys(...expected: Array) -> GdUnitDictionaryAssert: + return _not_contains_keys(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) + + +func contains_same_keys(expected :Array) -> GdUnitDictionaryAssert: + return _contains_keys(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func contains_same_key_value(key :Variant, value :Variant) -> GdUnitDictionaryAssert: + return _contains_key_value(key, value, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func not_contains_same_keys(...expected: Array) -> GdUnitDictionaryAssert: + return _not_contains_keys(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE) + + +func _filter_by_key(element :Variant, values :Array, compare_mode :GdObjects.COMPARE_MODE, is_not :bool = false) -> bool: + for key :Variant in values: + if GdObjects.equals(key, element, true, compare_mode): + return is_not + return !is_not + + +## Small helper to support the old expected arguments as single array and variadic arguments +func _extract_variadic_value(values: Variant) -> Variant: + @warning_ignore("unsafe_method_access") + if values != null and values.size() == 1 and GdArrayTools.is_array_type(values[0]): + return values[0] + return values diff --git a/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd.uid new file mode 100644 index 0000000..5e60b4e --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://cchun3g78pe1e diff --git a/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd new file mode 100644 index 0000000..2fb195b --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd @@ -0,0 +1,149 @@ +extends GdUnitFailureAssert + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +var _is_failed := false +var _failure_message := "" +var _stack_trace: GdUnitStackTrace +var _custom_failure_message := "" +var _additional_failure_message := "" + + +func _set_do_expect_fail(enabled :bool = true) -> void: + Engine.set_meta(GdUnitConstants.EXPECT_ASSERT_REPORT_FAILURES, enabled) + + +func execute_and_await(assertion :Callable, do_await := true) -> GdUnitFailureAssert: + # do not report any failure from the original assertion we want to test + _set_do_expect_fail(true) + var thread_context := GdUnitThreadManager.get_current_context() + thread_context.set_assert(null) + @warning_ignore("return_value_discarded") + GdUnitSignals.instance().gdunit_set_test_failed.connect(_on_test_failed) + # execute the given assertion as callable + if do_await: + await assertion.call() + else: + assertion.call() + _set_do_expect_fail(false) + + # get the assert instance from current tread context + var current_assert := thread_context.get_assert() + if not is_instance_of(current_assert, GdUnitAssert): + _is_failed = true + _failure_message = "Invalid Callable! It must be a callable of 'GdUnitAssert'" + return self + + var last_error := thread_context.get_execution_context().last_error() + if last_error != null: + _stack_trace = last_error._stack_trace + _failure_message = last_error._message + return self + + +func execute(assertion :Callable) -> GdUnitFailureAssert: + @warning_ignore("return_value_discarded") + execute_and_await(assertion, false) + return self + + +func _on_test_failed(value :bool) -> void: + _is_failed = value + + +func is_equal(_expected: Variant) -> GdUnitFailureAssert: + return _report_error("Not implemented") + + +func is_not_equal(_expected: Variant) -> GdUnitFailureAssert: + return _report_error("Not implemented") + + +func is_null() -> GdUnitFailureAssert: + return _report_error("Not implemented") + + +func is_not_null() -> GdUnitFailureAssert: + return _report_error("Not implemented") + + +func override_failure_message(message: String) -> GdUnitFailureAssert: + _custom_failure_message = message + return self + + +func append_failure_message(message: String) -> GdUnitFailureAssert: + _additional_failure_message = message + return self + + +func is_success() -> GdUnitFailureAssert: + if _is_failed: + return _report_error("Expect: assertion ends successfully.") + return self + + +func is_failed() -> GdUnitFailureAssert: + if not _is_failed: + return _report_error("Expect: assertion fails.") + return self + + +func has_line(expected :int) -> GdUnitFailureAssert: + var current := GdAssertReports.get_last_error_line_number() + if current != expected: + return _report_error("Expect: to failed on line '%d'\n but was '%d'." % [expected, current]) + return self + + +func has_stack_trace(stack_trace_elements: Array[GdUnitStackTraceElement]) -> GdUnitFailureAssert: + var current_stack_trace := str(_stack_trace) + var expected_stack_trace := str(GdUnitStackTrace.of(stack_trace_elements)) + if current_stack_trace != expected_stack_trace: + return _report_error(GdAssertMessages.error_equal(current_stack_trace, expected_stack_trace)) + return self + + +func has_message(expected :String) -> GdUnitFailureAssert: + @warning_ignore("return_value_discarded") + is_failed() + var expected_error := GdUnitTools.normalize_text(GdUnitTools.richtext_normalize(expected)) + var current_error := GdUnitTools.normalize_text(GdUnitTools.richtext_normalize(_failure_message)) + if current_error != expected_error: + var diffs := GdDiffTool.string_diff(current_error, expected_error) + var current := GdAssertMessages.colored_array_div(diffs[1]) + return _report_error(GdAssertMessages.error_not_same_error(current, expected_error)) + return self + + +func contains_message(expected :String) -> GdUnitFailureAssert: + var expected_error := GdUnitTools.normalize_text(expected) + var current_error := GdUnitTools.normalize_text(GdUnitTools.richtext_normalize(_failure_message)) + if not current_error.contains(expected_error): + var diffs := GdDiffTool.string_diff(current_error, expected_error) + var current := GdAssertMessages.colored_array_div(diffs[1]) + return _report_error(GdAssertMessages.error_not_same_error(current, expected_error)) + return self + + +func starts_with_message(expected :String) -> GdUnitFailureAssert: + var expected_error := GdUnitTools.normalize_text(expected) + var current_error := GdUnitTools.normalize_text(GdUnitTools.richtext_normalize(_failure_message)) + if current_error.find(expected_error) != 0: + var diffs := GdDiffTool.string_diff(current_error, expected_error) + var current := GdAssertMessages.colored_array_div(diffs[1]) + return _report_error(GdAssertMessages.error_not_same_error(current, expected_error)) + return self + + +func _report_error(error_message :String, failure_line_number: int = -1) -> GdUnitAssert: + var stack_trace := GdUnitStackTrace.new() + var line_number := failure_line_number if failure_line_number != -1 else stack_trace.get_line_number() + var failure_message := GdAssertMessages.build_failure_message(error_message, _additional_failure_message, _custom_failure_message) + GdAssertReports.report_error(GdUnitError.new(failure_message, line_number, stack_trace)) + return self + + +func _report_success() -> GdUnitFailureAssert: + GdAssertReports.report_success() + return self diff --git a/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd.uid new file mode 100644 index 0000000..6bb83d9 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://c8w7kgcqt1ob2 diff --git a/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd new file mode 100644 index 0000000..7df9985 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd @@ -0,0 +1,112 @@ +extends GdUnitFileAssert + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +var _base: GdUnitAssertImpl + + +func _init(current :Variant) -> void: + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + if not GdUnitAssertions.validate_value_type(current, TYPE_STRING): + @warning_ignore("return_value_discarded") + report_error("GdUnitFileAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current)) + + +func _notification(event :int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func current_value() -> String: + return _base.current_value() + + +func report_success() -> GdUnitFileAssert: + @warning_ignore("return_value_discarded") + _base.report_success() + return self + + +func report_error(error :String) -> GdUnitFileAssert: + @warning_ignore("return_value_discarded") + _base.report_error(error) + return self + + +func override_failure_message(message: String) -> GdUnitFileAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message: String) -> GdUnitFileAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func is_null() -> GdUnitFileAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitFileAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +func is_equal(expected: Variant) -> GdUnitFileAssert: + @warning_ignore("return_value_discarded") + _base.is_equal(expected) + return self + + +func is_not_equal(expected: Variant) -> GdUnitFileAssert: + @warning_ignore("return_value_discarded") + _base.is_not_equal(expected) + return self + + +func is_file() -> GdUnitFileAssert: + var current := current_value() + if FileAccess.open(current, FileAccess.READ) == null: + return report_error("Is not a file '%s', error code %s" % [current, FileAccess.get_open_error()]) + return report_success() + + +func exists() -> GdUnitFileAssert: + var current := current_value() + if not FileAccess.file_exists(current): + return report_error("The file '%s' not exists" %current) + return report_success() + + +func is_script() -> GdUnitFileAssert: + var current := current_value() + if FileAccess.open(current, FileAccess.READ) == null: + return report_error("Can't acces the file '%s'! Error code %s" % [current, FileAccess.get_open_error()]) + + var script := load(current) + if not script is GDScript: + return report_error("The file '%s' is not a GdScript" % current) + return report_success() + + +func contains_exactly(expected_rows: Array) -> GdUnitFileAssert: + var current := current_value() + if FileAccess.open(current, FileAccess.READ) == null: + return report_error("Can't acces the file '%s'! Error code %s" % [current, FileAccess.get_open_error()]) + + var script: GDScript = load(current) + if script is GDScript: + var source_code := GdScriptParser.to_unix_format(script.source_code) + var rows := Array(source_code.split("\n")) + @warning_ignore("return_value_discarded") + GdUnitArrayAssertImpl.new(rows).contains_exactly(expected_rows) + return self diff --git a/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd.uid new file mode 100644 index 0000000..d72d140 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://dswrjmhcajtgi diff --git a/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd new file mode 100644 index 0000000..a1b422e --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd @@ -0,0 +1,155 @@ +extends GdUnitFloatAssert + +var _base: GdUnitAssertImpl + + +func _init(current :Variant) -> void: + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + if not GdUnitAssertions.validate_value_type(current, TYPE_FLOAT): + @warning_ignore("return_value_discarded") + report_error("GdUnitFloatAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current)) + + +func _notification(event :int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func current_value() -> Variant: + return _base.current_value() + + +func report_success() -> GdUnitFloatAssert: + @warning_ignore("return_value_discarded") + _base.report_success() + return self + + +func report_error(error :String) -> GdUnitFloatAssert: + @warning_ignore("return_value_discarded") + _base.report_error(error) + return self + + +func override_failure_message(message: String) -> GdUnitFloatAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message: String) -> GdUnitFloatAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func is_null() -> GdUnitFloatAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitFloatAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +func is_equal(expected: Variant) -> GdUnitFloatAssert: + @warning_ignore("return_value_discarded") + _base.is_equal(expected) + return self + + +func is_not_equal(expected: Variant) -> GdUnitFloatAssert: + @warning_ignore("return_value_discarded") + _base.is_not_equal(expected) + return self + + +@warning_ignore("shadowed_global_identifier") +func is_equal_approx(expected :float, approx :float) -> GdUnitFloatAssert: + return is_between(expected-approx, expected+approx) + + +func is_less(expected :float) -> GdUnitFloatAssert: + var current :Variant = current_value() + if current == null or current >= expected: + return report_error(GdAssertMessages.error_is_value(Comparator.LESS_THAN, current, expected)) + return report_success() + + +func is_less_equal(expected :float) -> GdUnitFloatAssert: + var current :Variant = current_value() + if current == null or current > expected: + return report_error(GdAssertMessages.error_is_value(Comparator.LESS_EQUAL, current, expected)) + return report_success() + + +func is_greater(expected :float) -> GdUnitFloatAssert: + var current :Variant = current_value() + if current == null or current <= expected: + return report_error(GdAssertMessages.error_is_value(Comparator.GREATER_THAN, current, expected)) + return report_success() + + +func is_greater_equal(expected :float) -> GdUnitFloatAssert: + var current :Variant = current_value() + if current == null or current < expected: + return report_error(GdAssertMessages.error_is_value(Comparator.GREATER_EQUAL, current, expected)) + return report_success() + + +func is_negative() -> GdUnitFloatAssert: + var current :Variant = current_value() + if current == null or current >= 0.0: + return report_error(GdAssertMessages.error_is_negative(current)) + return report_success() + + +func is_not_negative() -> GdUnitFloatAssert: + var current :Variant = current_value() + if current == null or current < 0.0: + return report_error(GdAssertMessages.error_is_not_negative(current)) + return report_success() + + +func is_zero() -> GdUnitFloatAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current == null or not is_equal_approx(0.00000000, current as float): + return report_error(GdAssertMessages.error_is_zero(current)) + return report_success() + + +func is_not_zero() -> GdUnitFloatAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current == null or is_equal_approx(0.00000000, current as float): + return report_error(GdAssertMessages.error_is_not_zero()) + return report_success() + + +func is_in(expected :Array) -> GdUnitFloatAssert: + var current :Variant = current_value() + if not expected.has(current): + return report_error(GdAssertMessages.error_is_in(current, expected)) + return report_success() + + +func is_not_in(expected :Array) -> GdUnitFloatAssert: + var current :Variant = current_value() + if expected.has(current): + return report_error(GdAssertMessages.error_is_not_in(current, expected)) + return report_success() + + +func is_between(from :float, to :float) -> GdUnitFloatAssert: + var current :Variant = current_value() + if current == null or current < from or current > to: + return report_error(GdAssertMessages.error_is_value(Comparator.BETWEEN_EQUAL, current, from, to)) + return report_success() diff --git a/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd.uid new file mode 100644 index 0000000..b4698ac --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://capj6hodgg868 diff --git a/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd new file mode 100644 index 0000000..0df7389 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd @@ -0,0 +1,172 @@ +extends GdUnitFuncAssert + + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const DEFAULT_TIMEOUT := 2000 + + +var _current_value_provider :ValueProvider +var _custom_failure_message :String = "" +var _additional_failure_message: String = "" +var _line_number := -1 +var _timeout := DEFAULT_TIMEOUT +var _interrupted := false +var _sleep_timer :Timer = null + + +func _init(instance :Object, func_name :String, args := Array()) -> void: + _line_number = GdUnitStackTrace.new().get_line_number() + GdAssertReports.reset_last_error_line_number() + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + # verify at first the function name exists + if not instance.has_method(func_name): + @warning_ignore("return_value_discarded") + report_error("The function '%s' do not exists checked instance '%s'." % [func_name, instance]) + _interrupted = true + else: + _current_value_provider = CallBackValueProvider.new(instance, func_name, args) + + +func _notification(what :int) -> void: + if what == NOTIFICATION_PREDELETE: + _interrupted = true + var main_node :Node = (Engine.get_main_loop() as SceneTree).root + if is_instance_valid(_current_value_provider): + _current_value_provider.dispose() + _current_value_provider = null + if is_instance_valid(_sleep_timer): + _sleep_timer.set_wait_time(0.0001) + _sleep_timer.stop() + main_node.remove_child(_sleep_timer) + _sleep_timer.free() + _sleep_timer = null + + +func report_success() -> GdUnitFuncAssert: + GdAssertReports.report_success() + return self + + +func report_error(failure :String) -> GdUnitFuncAssert: + var failure_message := GdAssertMessages.build_failure_message(failure, _additional_failure_message, _custom_failure_message) + GdAssertReports.report_error(GdUnitError.new(failure_message, _line_number, GdUnitStackTrace.new())) + return self + + +func override_failure_message(message: String) -> GdUnitFuncAssert: + _custom_failure_message = message + return self + + +func append_failure_message(message: String) -> GdUnitFuncAssert: + _additional_failure_message = message + return self + + +func wait_until(timeout := 2000) -> GdUnitFuncAssert: + if timeout <= 0: + push_warning("Invalid timeout param, alloed timeouts must be grater than 0. Use default timeout instead") + _timeout = DEFAULT_TIMEOUT + else: + _timeout = timeout + return self + + +func is_null() -> GdUnitFuncAssert: + await _validate_callback(cb_is_null) + return self + + +func is_not_null() -> GdUnitFuncAssert: + await _validate_callback(cb_is_not_null) + return self + + +func is_false() -> GdUnitFuncAssert: + await _validate_callback(cb_is_false) + return self + + +func is_true() -> GdUnitFuncAssert: + await _validate_callback(cb_is_true) + return self + + +func is_equal(expected: Variant) -> GdUnitFuncAssert: + await _validate_callback(cb_is_equal, expected) + return self + + +func is_not_equal(expected: Variant) -> GdUnitFuncAssert: + await _validate_callback(cb_is_not_equal, expected) + return self + + +# we need actually to define this Callable as functions otherwise we results into leaked scripts here +# this is actually a Godot bug and needs this kind of workaround +func cb_is_null(c :Variant, _e :Variant) -> bool: return c == null +func cb_is_not_null(c :Variant, _e :Variant) -> bool: return c != null +func cb_is_false(c :Variant, _e :Variant) -> bool: return c == false +func cb_is_true(c :Variant, _e :Variant) -> bool: return c == true +func cb_is_equal(c :Variant, e :Variant) -> bool: return GdObjects.equals(c,e) +func cb_is_not_equal(c :Variant, e :Variant) -> bool: return not GdObjects.equals(c, e) + + +func do_interrupt() -> void: + _interrupted = true + + +func _validate_callback(predicate :Callable, expected :Variant = null) -> void: + if _interrupted: + return + GdUnitMemoryObserver.guard_instance(self) + var time_scale := Engine.get_time_scale() + var timer := Timer.new() + timer.set_name("gdunit_funcassert_interrupt_timer_%d" % timer.get_instance_id()) + var scene_tree := Engine.get_main_loop() as SceneTree + scene_tree.root.add_child(timer) + timer.add_to_group("GdUnitTimers") + @warning_ignore("return_value_discarded") + timer.timeout.connect(do_interrupt, CONNECT_DEFERRED) + timer.set_one_shot(true) + timer.start((_timeout/1000.0)*time_scale) + _sleep_timer = Timer.new() + _sleep_timer.set_name("gdunit_funcassert_sleep_timer_%d" % _sleep_timer.get_instance_id() ) + scene_tree.root.add_child(_sleep_timer) + + while true: + var current :Variant = await next_current_value() + # is interupted or predicate success + if _interrupted or predicate.call(current, expected): + break + if is_instance_valid(_sleep_timer): + _sleep_timer.start(0.05) + await _sleep_timer.timeout + + _sleep_timer.stop() + await scene_tree.process_frame + if _interrupted: + # https://github.com/godotengine/godot/issues/73052 + #var predicate_name = predicate.get_method() + var predicate_name :String = str(predicate).split('::')[1] + @warning_ignore("return_value_discarded") + report_error(GdAssertMessages.error_interrupted( + predicate_name.strip_edges().trim_prefix("cb_"), + expected, + LocalTime.elapsed(_timeout) + ) + ) + else: + @warning_ignore("return_value_discarded") + report_success() + _sleep_timer.free() + timer.free() + GdUnitMemoryObserver.unguard_instance(self) + + +func next_current_value() -> Variant: + @warning_ignore("redundant_await") + if is_instance_valid(_current_value_provider): + return await _current_value_provider.get_value() + return "invalid value" diff --git a/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd.uid new file mode 100644 index 0000000..d8f256d --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://ccwcmlf6u4p7m diff --git a/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd new file mode 100644 index 0000000..a868987 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd @@ -0,0 +1,147 @@ +extends GdUnitGodotErrorAssert + +var _custom_failure_message := "" +var _additional_failure_message := "" +var _callable: Callable +var _logger := GodotGdErrorMonitor.GdUnitLogger.new(true, true) + + +func _init(callable: Callable) -> void: + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + GdAssertReports.reset_last_error_line_number() + _callable = callable + + +func _execute() -> Array[ErrorLogEntry]: + await _callable.call() + return _logger.entries() + + +func _report_success() -> GdUnitAssert: + GdAssertReports.report_success() + return self + + +func _report_error(error_message: String, failure_line_number: int = -1) -> GdUnitAssert: + var stack_trace := GdUnitStackTrace.new() + var line_number := failure_line_number if failure_line_number != -1 else stack_trace.get_line_number() + var failure_message := GdAssertMessages.build_failure_message(error_message, _additional_failure_message, _custom_failure_message) + GdAssertReports.report_error(GdUnitError.new(failure_message, line_number, stack_trace)) + return self + + +func _has_log_entry(log_entries: Array[ErrorLogEntry], type: ErrorLogEntry.TYPE, error: Variant) -> bool: + for entry in log_entries: + if entry._type == type and GdObjects.equals(entry._message, error): + GdUnitThreadManager.get_current_context().get_execution_context().error_monitor.erase_log_entry(entry) + return true + return false + + +func _to_list(log_entries: Array[ErrorLogEntry]) -> String: + if log_entries.is_empty(): + return "no errors" + + var values := [] + for entry in log_entries: + values.append(entry) + return "\n".join(values) + + +func is_null() -> GdUnitGodotErrorAssert: + return _report_error("Not implemented") + + +func is_not_null() -> GdUnitGodotErrorAssert: + return _report_error("Not implemented") + + +func is_equal(_expected: Variant) -> GdUnitGodotErrorAssert: + return _report_error("Not implemented") + + +func is_not_equal(_expected: Variant) -> GdUnitGodotErrorAssert: + return _report_error("Not implemented") + + +func override_failure_message(message: String) -> GdUnitGodotErrorAssert: + _custom_failure_message = message + return self + + +func append_failure_message(message: String) -> GdUnitGodotErrorAssert: + _additional_failure_message = message + return self + + +func is_success() -> GdUnitGodotErrorAssert: + if not _validate_callable(): + return self + + var log_entries := await _execute() + if log_entries.is_empty(): + return _report_success() + return _report_error(""" + Expecting: no error's are ocured. + but found: '%s' + """.dedent().trim_prefix("\n") % _to_list(log_entries)) + + +func is_runtime_error(expected_error: Variant) -> GdUnitGodotErrorAssert: + if not _validate_callable(): + return self + + var result := GdUnitArgumentMatchers.is_variant_string_matching(expected_error) + if result.is_error(): + return _report_error(result.error_message()) + var log_entries := await _execute() + if _has_log_entry(log_entries, ErrorLogEntry.TYPE.SCRIPT_ERROR, expected_error): + return _report_success() + return _report_error(""" + Expecting: a runtime error is triggered. + expected: '%s' + current: '%s' + """.dedent().trim_prefix("\n") % [expected_error, _to_list(log_entries)]) + + +func is_push_warning(expected_warning: Variant) -> GdUnitGodotErrorAssert: + if not _validate_callable(): + return self + + var result := GdUnitArgumentMatchers.is_variant_string_matching(expected_warning) + if result.is_error(): + return _report_error(result.error_message()) + var log_entries := await _execute() + if _has_log_entry(log_entries, ErrorLogEntry.TYPE.PUSH_WARNING, expected_warning): + return _report_success() + return _report_error(""" + Expecting: push_warning() is called. + expected: '%s' + current: '%s' + """.dedent().trim_prefix("\n") % [expected_warning, _to_list(log_entries)]) + + +func is_push_error(expected_error: Variant) -> GdUnitGodotErrorAssert: + if not _validate_callable(): + return self + + var result := GdUnitArgumentMatchers.is_variant_string_matching(expected_error) + if result.is_error(): + return _report_error(result.error_message()) + var log_entries := await _execute() + if _has_log_entry(log_entries, ErrorLogEntry.TYPE.PUSH_ERROR, expected_error): + return _report_success() + return _report_error(""" + Expecting: push_error() is called. + expected: '%s' + current: '%s' + """.dedent().trim_prefix("\n") % [expected_error, _to_list(log_entries)]) + + +func _validate_callable() -> bool: + if _callable == null or not _callable.is_valid(): + @warning_ignore("return_value_discarded") + _report_error("Invalid Callable '%s'" % _callable) + return false + return true diff --git a/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd.uid new file mode 100644 index 0000000..5cca495 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://63bqmdkxpfpo diff --git a/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd new file mode 100644 index 0000000..7f17e86 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd @@ -0,0 +1,162 @@ +extends GdUnitIntAssert + +var _base: GdUnitAssertImpl + + +func _init(current :Variant) -> void: + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + if not GdUnitAssertions.validate_value_type(current, TYPE_INT): + @warning_ignore("return_value_discarded") + report_error("GdUnitIntAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current)) + + +func _notification(event :int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func current_value() -> Variant: + return _base.current_value() + + +func report_success() -> GdUnitIntAssert: + @warning_ignore("return_value_discarded") + _base.report_success() + return self + + +func report_error(error :String) -> GdUnitIntAssert: + @warning_ignore("return_value_discarded") + _base.report_error(error) + return self + + +func override_failure_message(message: String) -> GdUnitIntAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message: String) -> GdUnitIntAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func is_null() -> GdUnitIntAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitIntAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +func is_equal(expected: Variant) -> GdUnitIntAssert: + @warning_ignore("return_value_discarded") + _base.is_equal(expected) + return self + + +func is_not_equal(expected: Variant) -> GdUnitIntAssert: + @warning_ignore("return_value_discarded") + _base.is_not_equal(expected) + return self + + +func is_less(expected :int) -> GdUnitIntAssert: + var current :Variant = current_value() + if current == null or current >= expected: + return report_error(GdAssertMessages.error_is_value(Comparator.LESS_THAN, current, expected)) + return report_success() + + +func is_less_equal(expected :int) -> GdUnitIntAssert: + var current :Variant = current_value() + if current == null or current > expected: + return report_error(GdAssertMessages.error_is_value(Comparator.LESS_EQUAL, current, expected)) + return report_success() + + +func is_greater(expected :int) -> GdUnitIntAssert: + var current :Variant = current_value() + if current == null or current <= expected: + return report_error(GdAssertMessages.error_is_value(Comparator.GREATER_THAN, current, expected)) + return report_success() + + +func is_greater_equal(expected :int) -> GdUnitIntAssert: + var current :Variant = current_value() + if current == null or current < expected: + return report_error(GdAssertMessages.error_is_value(Comparator.GREATER_EQUAL, current, expected)) + return report_success() + + +func is_even() -> GdUnitIntAssert: + var current :Variant = current_value() + if current == null or current % 2 != 0: + return report_error(GdAssertMessages.error_is_even(current)) + return report_success() + + +func is_odd() -> GdUnitIntAssert: + var current :Variant = current_value() + if current == null or current % 2 == 0: + return report_error(GdAssertMessages.error_is_odd(current)) + return report_success() + + +func is_negative() -> GdUnitIntAssert: + var current :Variant = current_value() + if current == null or current >= 0: + return report_error(GdAssertMessages.error_is_negative(current)) + return report_success() + + +func is_not_negative() -> GdUnitIntAssert: + var current :Variant = current_value() + if current == null or current < 0: + return report_error(GdAssertMessages.error_is_not_negative(current)) + return report_success() + + +func is_zero() -> GdUnitIntAssert: + var current :Variant = current_value() + if current != 0: + return report_error(GdAssertMessages.error_is_zero(current)) + return report_success() + + +func is_not_zero() -> GdUnitIntAssert: + var current :Variant= current_value() + if current == 0: + return report_error(GdAssertMessages.error_is_not_zero()) + return report_success() + + +func is_in(expected :Array) -> GdUnitIntAssert: + var current :Variant = current_value() + if not expected.has(current): + return report_error(GdAssertMessages.error_is_in(current, expected)) + return report_success() + + +func is_not_in(expected :Array) -> GdUnitIntAssert: + var current :Variant = current_value() + if expected.has(current): + return report_error(GdAssertMessages.error_is_not_in(current, expected)) + return report_success() + + +func is_between(from :int, to :int) -> GdUnitIntAssert: + var current :Variant = current_value() + if current == null or current < from or current > to: + return report_error(GdAssertMessages.error_is_value(Comparator.BETWEEN_EQUAL, current, from, to)) + return report_success() diff --git a/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd.uid new file mode 100644 index 0000000..3e6b0ed --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://dk7n5ekqsfgxw diff --git a/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd new file mode 100644 index 0000000..b4bc8d7 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd @@ -0,0 +1,169 @@ +extends GdUnitObjectAssert + +var _base: GdUnitAssertImpl + + +func _init(current: Variant) -> void: + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + if (current != null + and (GdUnitAssertions.validate_value_type(current, TYPE_BOOL) + or GdUnitAssertions.validate_value_type(current, TYPE_INT) + or GdUnitAssertions.validate_value_type(current, TYPE_FLOAT) + or GdUnitAssertions.validate_value_type(current, TYPE_STRING))): + @warning_ignore("return_value_discarded") + report_error("GdUnitObjectAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current)) + + +func _notification(event: int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func current_value() -> Variant: + return _base.current_value() + + +func report_success() -> GdUnitObjectAssert: + @warning_ignore("return_value_discarded") + _base.report_success() + return self + + +func report_error(error: String) -> GdUnitObjectAssert: + @warning_ignore("return_value_discarded") + _base.report_error(error) + return self + + +func override_failure_message(message: String) -> GdUnitObjectAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message: String) -> GdUnitObjectAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func is_equal(expected: Variant) -> GdUnitObjectAssert: + @warning_ignore("return_value_discarded") + _base.is_equal(expected) + return self + + +func is_not_equal(expected: Variant) -> GdUnitObjectAssert: + @warning_ignore("return_value_discarded") + _base.is_not_equal(expected) + return self + + +func is_null() -> GdUnitObjectAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitObjectAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +@warning_ignore("shadowed_global_identifier") +func is_same(expected: Variant) -> GdUnitObjectAssert: + var current: Variant = current_value() + if not is_same(current, expected): + return report_error(GdAssertMessages.error_is_same(current, expected)) + return report_success() + + +func is_not_same(expected: Variant) -> GdUnitObjectAssert: + var current: Variant = current_value() + if is_same(current, expected): + return report_error(GdAssertMessages.error_not_same(current, expected)) + return report_success() + + +func is_instanceof(type: Variant) -> GdUnitObjectAssert: + var current: Variant = current_value() + if current == null or not is_instance_of(current, type): + var result_expected := GdObjects.extract_class_name(type) + var result_current := GdObjects.extract_class_name(current) + return report_error(GdAssertMessages.error_is_instanceof(result_current, result_expected)) + return report_success() + + +func is_not_instanceof(type: Variant) -> GdUnitObjectAssert: + var current: Variant = current_value() + if is_instance_of(current, type): + var result := GdObjects.extract_class_name(type) + if result.is_success(): + return report_error("Expected not be a instance of <%s>" % str(result.value())) + + push_error("Internal ERROR: %s" % result.error_message()) + return self + return report_success() + + +## Checks whether the current object inherits from the specified type. +func is_inheriting(type: Variant) -> GdUnitObjectAssert: + var current: Variant = current_value() + if not is_instance_of(current, TYPE_OBJECT): + return report_error("Expected '%s' to inherit from at least Object." % str(current)) + var result := _inherits(current, type) + if result.is_success(): + return report_success() + return report_error(result.error_message()) + + +## Checks whether the current object does NOT inherit from the specified type. +func is_not_inheriting(type: Variant) -> GdUnitObjectAssert: + var current: Variant = current_value() + if not is_instance_of(current, TYPE_OBJECT): + return report_error("Expected '%s' to inherit from at least Object." % str(current)) + var result := _inherits(current, type) + if result.is_success(): + return report_error("Expected type to not inherit from <%s>" % _extract_class_type(type)) + return report_success() + + +func is_valid() -> GdUnitObjectAssert: + var current: Variant = current_value() + if not is_instance_valid(current): + return report_error("Expecting the object is a valid unfreed instance.") + return report_success() + + +func _inherits(current: Variant, type: Variant) -> GdUnitResult: + var type_as_string := _extract_class_type(type) + if type_as_string == "Object": + return GdUnitResult.success("") + + var obj: Object = current + for p in obj.get_property_list(): + var clazz_name :String = p["name"] + if p["usage"] == PROPERTY_USAGE_CATEGORY and clazz_name == p["hint_string"] and clazz_name == type_as_string: + return GdUnitResult.success("") + var script: Script = obj.get_script() + if script != null: + while script != null: + var result := GdObjects.extract_class_name(script) + if result.is_success() and result.value() == type_as_string: + return GdUnitResult.success("") + script = script.get_base_script() + return GdUnitResult.error("Expected type to inherit from <%s>" % type_as_string) + + +func _extract_class_type(type: Variant) -> String: + if type is String: + return type + var result := GdObjects.extract_class_name(type) + if result.is_error(): + return "" + return result.value() diff --git a/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd.uid new file mode 100644 index 0000000..24adb8a --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://biayaljmla5am diff --git a/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd new file mode 100644 index 0000000..b45c46d --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd @@ -0,0 +1,124 @@ +extends GdUnitResultAssert + +var _base: GdUnitAssertImpl + + +func _init(current :Variant) -> void: + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + if not validate_value_type(current): + @warning_ignore("return_value_discarded") + report_error("GdUnitResultAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current)) + + +func _notification(event :int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func validate_value_type(value :Variant) -> bool: + return value == null or value is GdUnitResult + + +func current_value() -> GdUnitResult: + return _base.current_value() + + +func report_success() -> GdUnitResultAssert: + @warning_ignore("return_value_discarded") + _base.report_success() + return self + + +func report_error(error :String) -> GdUnitResultAssert: + @warning_ignore("return_value_discarded") + _base.report_error(error) + return self + + +func override_failure_message(message: String) -> GdUnitResultAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message: String) -> GdUnitResultAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func is_null() -> GdUnitResultAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitResultAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +func is_equal(expected: Variant) -> GdUnitResultAssert: + return is_value(expected) + + +func is_not_equal(expected: Variant) -> GdUnitResultAssert: + var result := current_value() + var value :Variant = null if result == null else result.value() + if GdObjects.equals(value, expected): + return report_error(GdAssertMessages.error_not_equal(value, expected)) + return report_success() + + +func is_empty() -> GdUnitResultAssert: + var result := current_value() + if result == null or not result.is_empty(): + return report_error(GdAssertMessages.error_result_is_empty(result)) + return report_success() + + +func is_success() -> GdUnitResultAssert: + var result := current_value() + if result == null or not result.is_success(): + return report_error(GdAssertMessages.error_result_is_success(result)) + return report_success() + + +func is_warning() -> GdUnitResultAssert: + var result := current_value() + if result == null or not result.is_warn(): + return report_error(GdAssertMessages.error_result_is_warning(result)) + return report_success() + + +func is_error() -> GdUnitResultAssert: + var result := current_value() + if result == null or not result.is_error(): + return report_error(GdAssertMessages.error_result_is_error(result)) + return report_success() + + +func contains_message(expected :String) -> GdUnitResultAssert: + var result := current_value() + if result == null: + return report_error(GdAssertMessages.error_result_has_message("", expected)) + if result.is_success(): + return report_error(GdAssertMessages.error_result_has_message_on_success(expected)) + if result.is_error() and result.error_message() != expected: + return report_error(GdAssertMessages.error_result_has_message(result.error_message(), expected)) + if result.is_warn() and result.warn_message() != expected: + return report_error(GdAssertMessages.error_result_has_message(result.warn_message(), expected)) + return report_success() + + +func is_value(expected: Variant) -> GdUnitResultAssert: + var result := current_value() + var value :Variant = null if result == null else result.value() + if not GdObjects.equals(value, expected): + return report_error(GdAssertMessages.error_result_is_value(value, expected)) + return report_success() diff --git a/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd.uid new file mode 100644 index 0000000..99d3abe --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://dawhhqwat8ysw diff --git a/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd new file mode 100644 index 0000000..99ca544 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd @@ -0,0 +1,167 @@ +extends GdUnitSignalAssert + +const DEFAULT_TIMEOUT := 2000 + +var _signal_collector :GdUnitSignalCollector +var _emitter :Object +var _custom_failure_message :String = "" +var _additional_failure_message: String = "" +var _line_number := -1 +var _timeout := DEFAULT_TIMEOUT +var _interrupted := false + + +func _init(emitter :Object) -> void: + # save the actual assert instance on the current thread context + var context := GdUnitThreadManager.get_current_context() + context.set_assert(self) + _signal_collector = context.get_signal_collector() + _line_number = GdUnitStackTrace.new().get_line_number() + _emitter = emitter + GdAssertReports.reset_last_error_line_number() + + +func _notification(what :int) -> void: + if what == NOTIFICATION_PREDELETE: + _interrupted = true + if is_instance_valid(_emitter): + _signal_collector.unregister_emitter(_emitter) + _emitter = null + + +func report_success() -> GdUnitAssert: + GdAssertReports.report_success() + return self + + +func report_warning(message :String) -> GdUnitAssert: + GdAssertReports.report_warning(message, GdUnitStackTrace.new().get_line_number()) + return self + + +func report_error(failure :String) -> GdUnitAssert: + var failure_message := GdAssertMessages.build_failure_message(failure, _additional_failure_message, _custom_failure_message) + GdAssertReports.report_error(GdUnitError.new(failure_message, _line_number, GdUnitStackTrace.new())) + return self + + +func override_failure_message(message: String) -> GdUnitSignalAssert: + _custom_failure_message = message + return self + + +func append_failure_message(message: String) -> GdUnitSignalAssert: + _additional_failure_message = message + return self + + +func wait_until(timeout := 2000) -> GdUnitSignalAssert: + if timeout <= 0: + @warning_ignore("return_value_discarded") + report_warning("Invalid timeout parameter, allowed timeouts must be greater than 0, use default timeout instead!") + _timeout = DEFAULT_TIMEOUT + else: + _timeout = timeout + return self + + +func is_null() -> GdUnitSignalAssert: + if _emitter != null: + return report_error(GdAssertMessages.error_is_null(_emitter)) + return report_success() + + +func is_not_null() -> GdUnitSignalAssert: + if _emitter == null: + return report_error(GdAssertMessages.error_is_not_null()) + return report_success() + + +func is_equal(_expected: Variant) -> GdUnitSignalAssert: + return report_error("Not implemented") + + +func is_not_equal(_expected: Variant) -> GdUnitSignalAssert: + return report_error("Not implemented") + + +# Verifies the signal exists checked the emitter +func is_signal_exists(signal_or_name: Variant) -> GdUnitSignalAssert: + if not (signal_or_name is String or signal_or_name is Signal): + return report_error("Invalid signal_name: expected String or Signal, but is '%s'" % type_string(typeof(signal_or_name))) + + var signal_name := _to_signal_name(signal_or_name) + + if not _emitter.has_signal(signal_name): + @warning_ignore("return_value_discarded") + report_error("The signal '%s' not exists checked object '%s'." % [signal_name, _emitter.get_class()]) + return self + + +# Verifies that given signal is emitted until waiting time +func is_emitted(signal_name: Variant, ...signal_args: Array) -> GdUnitSignalAssert: + _line_number = GdUnitStackTrace.new().get_line_number() + @warning_ignore("unsafe_call_argument") + return await _wail_until_signal( + signal_name, + _wrap_arguments.callv(signal_args), + false) + + +# Verifies that given signal is NOT emitted until waiting time +func is_not_emitted(signal_name: Variant, ...signal_args: Array) -> GdUnitSignalAssert: + _line_number = GdUnitStackTrace.new().get_line_number() + @warning_ignore("unsafe_call_argument") + return await _wail_until_signal( + signal_name, + _wrap_arguments.callv(signal_args), + true) + + +func _wrap_arguments(...args: Array) -> Array: + # Check using old syntax + if not args.is_empty() and args[0] is Array: + return args[0] + return args + + +func _wail_until_signal(signal_or_name: Variant, expected_args: Array, expect_not_emitted: bool) -> GdUnitSignalAssert: + if _emitter == null: + return report_error("Can't wait for signal checked a NULL object.") + if not (signal_or_name is String or signal_or_name is Signal): + return report_error("Invalid signal_name: expected String or Signal, but is '%s'" % type_string(typeof(signal_or_name))) + + var signal_name := _to_signal_name(signal_or_name) + # first verify the signal is defined + if not _emitter.has_signal(signal_name): + return report_error("Can't wait for non-existion signal '%s' checked object '%s'." % [signal_name,_emitter.get_class()]) + _signal_collector.register_emitter(_emitter) + var time_scale := Engine.get_time_scale() + var timer := Timer.new() + (Engine.get_main_loop() as SceneTree).root.add_child(timer) + timer.add_to_group("GdUnitTimers") + timer.set_one_shot(true) + @warning_ignore("return_value_discarded") + timer.timeout.connect(func on_timeout() -> void: _interrupted = true) + timer.start((_timeout/1000.0)*time_scale) + var is_signal_emitted := false + while not _interrupted and not is_signal_emitted: + await (Engine.get_main_loop() as SceneTree).process_frame + if is_instance_valid(_emitter): + is_signal_emitted = _signal_collector.match(_emitter, signal_name, expected_args) + if is_signal_emitted and expect_not_emitted: + @warning_ignore("return_value_discarded") + report_error(GdAssertMessages.error_signal_emitted(signal_name, expected_args, LocalTime.elapsed(int(_timeout-timer.time_left*1000)))) + + if _interrupted and not expect_not_emitted: + @warning_ignore("return_value_discarded") + report_error(GdAssertMessages.error_wait_signal(signal_name, expected_args, LocalTime.elapsed(_timeout))) + timer.free() + if is_instance_valid(_emitter): + _signal_collector.reset_received_signals(_emitter, signal_name, expected_args) + return self + + +func _to_signal_name(signal_or_name: Variant) -> String: + @warning_ignore("unsafe_cast") + return (signal_or_name as Signal).get_name() if signal_or_name is Signal else signal_or_name diff --git a/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd.uid new file mode 100644 index 0000000..7b97225 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://iojn7sklh31q diff --git a/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd new file mode 100644 index 0000000..454631c --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd @@ -0,0 +1,204 @@ +extends GdUnitStringAssert + +var _base: GdUnitAssertImpl + + +func _init(current :Variant) -> void: + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + if current != null and typeof(current) != TYPE_STRING and typeof(current) != TYPE_STRING_NAME: + @warning_ignore("return_value_discarded") + report_error("GdUnitStringAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current)) + + +func _notification(event :int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func current_value() -> Variant: + return _base.current_value() + + +func report_success() -> GdUnitStringAssert: + @warning_ignore("return_value_discarded") + _base.report_success() + return self + + +func report_error(error :String) -> GdUnitStringAssert: + @warning_ignore("return_value_discarded") + _base.report_error(error) + return self + + +func override_failure_message(message: String) -> GdUnitStringAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message: String) -> GdUnitStringAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func is_null() -> GdUnitStringAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitStringAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +func is_equal(expected: Variant) -> GdUnitStringAssert: + return _is_equal(expected, true, GdAssertMessages.error_equal) + + +func is_equal_ignoring_case(expected: Variant) -> GdUnitStringAssert: + return _is_equal(expected, false, GdAssertMessages.error_equal_ignoring_case) + + +@warning_ignore_start("unsafe_call_argument") +func _is_equal(expected: Variant, case_sensitive: bool, message_cb: Callable) -> GdUnitStringAssert: + var current: Variant = current_value() + if current == null: + return report_error(message_cb.call(current, expected)) + var cur_value := str(current) + if not GdObjects.equals(cur_value, expected, case_sensitive): + var exp_value := str(expected) + if contains_bbcode(cur_value): + # mask user bbcode + # https://docs.godotengine.org/en/4.5/tutorials/ui/bbcode_in_richtextlabel.html#handling-user-input-safely + return report_error(message_cb.call(cur_value.replace("[", "[lb]"), exp_value.replace("[", "[lb]"))) + var diffs := GdDiffTool.string_diff(cur_value, exp_value) + var formatted_current := GdAssertMessages.colored_array_div(diffs[1]) + return report_error(message_cb.call(formatted_current, exp_value)) + return report_success() +@warning_ignore_restore("unsafe_call_argument") + + +func is_not_equal(expected: Variant) -> GdUnitStringAssert: + var current: Variant = current_value() + if GdObjects.equals(current, expected): + return report_error(GdAssertMessages.error_not_equal(current, expected)) + return report_success() + + +func is_not_equal_ignoring_case(expected :Variant) -> GdUnitStringAssert: + var current :Variant = current_value() + if GdObjects.equals(current, expected, false): + return report_error(GdAssertMessages.error_not_equal(current, expected)) + return report_success() + + +func is_empty() -> GdUnitStringAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current == null or not (current as String).is_empty(): + return report_error(GdAssertMessages.error_is_empty(current)) + return report_success() + + +func is_not_empty() -> GdUnitStringAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current == null or (current as String).is_empty(): + return report_error(GdAssertMessages.error_is_not_empty()) + return report_success() + + +func contains(expected :String) -> GdUnitStringAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current == null or (current as String).find(expected) == -1: + return report_error(GdAssertMessages.error_contains(current, expected)) + return report_success() + + +func not_contains(expected :String) -> GdUnitStringAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current != null and (current as String).find(expected) != -1: + return report_error(GdAssertMessages.error_not_contains(current, expected)) + return report_success() + + +func contains_ignoring_case(expected :String) -> GdUnitStringAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current == null or (current as String).findn(expected) == -1: + return report_error(GdAssertMessages.error_contains_ignoring_case(current, expected)) + return report_success() + + +func not_contains_ignoring_case(expected :String) -> GdUnitStringAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current != null and (current as String).findn(expected) != -1: + return report_error(GdAssertMessages.error_not_contains_ignoring_case(current, expected)) + return report_success() + + +func starts_with(expected :String) -> GdUnitStringAssert: + var current :Variant = current_value() + @warning_ignore("unsafe_cast") + if current == null or (current as String).find(expected) != 0: + return report_error(GdAssertMessages.error_starts_with(current, expected)) + return report_success() + + +func ends_with(expected :String) -> GdUnitStringAssert: + var current :Variant = current_value() + if current == null: + return report_error(GdAssertMessages.error_ends_with(current, expected)) + @warning_ignore("unsafe_cast") + var find :int = (current as String).length() - expected.length() + @warning_ignore("unsafe_cast") + if (current as String).rfind(expected) != find: + return report_error(GdAssertMessages.error_ends_with(current, expected)) + return report_success() + + +# gdlint:disable=max-returns +func has_length(expected :int, comparator := Comparator.EQUAL) -> GdUnitStringAssert: + var current :Variant = current_value() + if current == null: + return report_error(GdAssertMessages.error_has_length(current, expected, comparator)) + var str_current: String = current + match comparator: + Comparator.EQUAL: + if str_current.length() != expected: + return report_error(GdAssertMessages.error_has_length(str_current, expected, comparator)) + Comparator.LESS_THAN: + if str_current.length() >= expected: + return report_error(GdAssertMessages.error_has_length(str_current, expected, comparator)) + Comparator.LESS_EQUAL: + if str_current.length() > expected: + return report_error(GdAssertMessages.error_has_length(str_current, expected, comparator)) + Comparator.GREATER_THAN: + if str_current.length() <= expected: + return report_error(GdAssertMessages.error_has_length(str_current, expected, comparator)) + Comparator.GREATER_EQUAL: + if str_current.length() < expected: + return report_error(GdAssertMessages.error_has_length(str_current, expected, comparator)) + _: + return report_error("Comparator '%d' not implemented!" % comparator) + return report_success() + + +func contains_bbcode(value: String) -> bool: + var rtl := RichTextLabel.new() + rtl.bbcode_enabled = true + rtl.parse_bbcode(value) + var has_bbcode := rtl.get_parsed_text() != value + rtl.free() + return has_bbcode diff --git a/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd.uid new file mode 100644 index 0000000..5a2bce9 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://djhd1xljped6n diff --git a/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd new file mode 100644 index 0000000..2a53349 --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd @@ -0,0 +1,183 @@ +extends GdUnitVectorAssert + +var _base: GdUnitAssertImpl +var _current_type: int +var _type_check: bool + +func _init(current: Variant, type_check := true) -> void: + _type_check = type_check + _base = GdUnitAssertImpl.new(current) + # save the actual assert instance on the current thread context + GdUnitThreadManager.get_current_context().set_assert(self) + if not _validate_value_type(current): + @warning_ignore("return_value_discarded") + report_error("GdUnitVectorAssert error, the type <%s> is not supported." % GdObjects.typeof_as_string(current)) + _current_type = typeof(current) + + +func _notification(event :int) -> void: + if event == NOTIFICATION_PREDELETE: + if _base != null: + _base.notification(event) + _base = null + + +func _validate_value_type(value :Variant) -> bool: + return ( + value == null + or typeof(value) in [ + TYPE_VECTOR2, + TYPE_VECTOR2I, + TYPE_VECTOR3, + TYPE_VECTOR3I, + TYPE_VECTOR4, + TYPE_VECTOR4I + ] + ) + + +func _validate_is_vector_type(value :Variant) -> bool: + var type := typeof(value) + if type == _current_type or _current_type == TYPE_NIL: + return true + @warning_ignore("return_value_discarded") + report_error(GdAssertMessages.error_is_wrong_type(_current_type, type)) + return false + + +func current_value() -> Variant: + return _base.current_value() + + +func report_success() -> GdUnitVectorAssert: + @warning_ignore("return_value_discarded") + _base.report_success() + return self + + +func report_error(error :String) -> GdUnitVectorAssert: + @warning_ignore("return_value_discarded") + _base.report_error(error) + return self + + +func override_failure_message(message: String) -> GdUnitVectorAssert: + @warning_ignore("return_value_discarded") + _base.override_failure_message(message) + return self + + +func append_failure_message(message :String) -> GdUnitVectorAssert: + @warning_ignore("return_value_discarded") + _base.append_failure_message(message) + return self + + +func is_null() -> GdUnitVectorAssert: + @warning_ignore("return_value_discarded") + _base.is_null() + return self + + +func is_not_null() -> GdUnitVectorAssert: + @warning_ignore("return_value_discarded") + _base.is_not_null() + return self + + +func is_equal(expected: Variant) -> GdUnitVectorAssert: + if _type_check and not _validate_is_vector_type(expected): + return self + @warning_ignore("return_value_discarded") + _base.is_equal(expected) + return self + + +func is_not_equal(expected: Variant) -> GdUnitVectorAssert: + if _type_check and not _validate_is_vector_type(expected): + return self + @warning_ignore("return_value_discarded") + _base.is_not_equal(expected) + return self + + +@warning_ignore("shadowed_global_identifier") +func is_equal_approx(expected :Variant, approx :Variant) -> GdUnitVectorAssert: + if not _validate_is_vector_type(expected) or not _validate_is_vector_type(approx): + return self + var current :Variant = current_value() + var from :Variant = expected - approx + var to :Variant = expected + approx + if current == null or (not _is_equal_approx(current, from, to)): + return report_error(GdAssertMessages.error_is_value(Comparator.BETWEEN_EQUAL, current, from, to)) + return report_success() + + +func _is_equal_approx(current :Variant, from :Variant, to :Variant) -> bool: + match typeof(current): + TYPE_VECTOR2, TYPE_VECTOR2I: + return ((current.x >= from.x and current.y >= from.y) + and (current.x <= to.x and current.y <= to.y)) + TYPE_VECTOR3, TYPE_VECTOR3I: + return ((current.x >= from.x and current.y >= from.y and current.z >= from.z) + and (current.x <= to.x and current.y <= to.y and current.z <= to.z)) + TYPE_VECTOR4, TYPE_VECTOR4I: + return ((current.x >= from.x and current.y >= from.y and current.z >= from.z and current.w >= from.w) + and (current.x <= to.x and current.y <= to.y and current.z <= to.z and current.w <= to.w)) + _: + push_error("Missing implementation '_is_equal_approx' for vector type %s" % typeof(current)) + return false + + +func is_less(expected :Variant) -> GdUnitVectorAssert: + if not _validate_is_vector_type(expected): + return self + var current :Variant = current_value() + if current == null or current >= expected: + return report_error(GdAssertMessages.error_is_value(Comparator.LESS_THAN, current, expected)) + return report_success() + + +func is_less_equal(expected :Variant) -> GdUnitVectorAssert: + if not _validate_is_vector_type(expected): + return self + var current :Variant = current_value() + if current == null or current > expected: + return report_error(GdAssertMessages.error_is_value(Comparator.LESS_EQUAL, current, expected)) + return report_success() + + +func is_greater(expected :Variant) -> GdUnitVectorAssert: + if not _validate_is_vector_type(expected): + return self + var current :Variant = current_value() + if current == null or current <= expected: + return report_error(GdAssertMessages.error_is_value(Comparator.GREATER_THAN, current, expected)) + return report_success() + + +func is_greater_equal(expected :Variant) -> GdUnitVectorAssert: + if not _validate_is_vector_type(expected): + return self + var current :Variant = current_value() + if current == null or current < expected: + return report_error(GdAssertMessages.error_is_value(Comparator.GREATER_EQUAL, current, expected)) + return report_success() + + +func is_between(from :Variant, to :Variant) -> GdUnitVectorAssert: + if not _validate_is_vector_type(from) or not _validate_is_vector_type(to): + return self + var current :Variant = current_value() + if current == null or not (current >= from and current <= to): + return report_error(GdAssertMessages.error_is_value(Comparator.BETWEEN_EQUAL, current, from, to)) + return report_success() + + +func is_not_between(from :Variant, to :Variant) -> GdUnitVectorAssert: + if not _validate_is_vector_type(from) or not _validate_is_vector_type(to): + return self + var current :Variant = current_value() + if (current != null and current >= from and current <= to): + return report_error(GdAssertMessages.error_is_value(Comparator.NOT_BETWEEN_EQUAL, current, from, to)) + return report_success() diff --git a/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd.uid new file mode 100644 index 0000000..f552c5e --- /dev/null +++ b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd.uid @@ -0,0 +1 @@ +uid://rw4e2a32t5ob diff --git a/addons/gdUnit4/src/asserts/ValueProvider.gd b/addons/gdUnit4/src/asserts/ValueProvider.gd new file mode 100644 index 0000000..be01f70 --- /dev/null +++ b/addons/gdUnit4/src/asserts/ValueProvider.gd @@ -0,0 +1,10 @@ +# base interface for assert value provider +class_name ValueProvider +extends RefCounted + +func get_value() -> Variant: + return null + + +func dispose() -> void: + pass diff --git a/addons/gdUnit4/src/asserts/ValueProvider.gd.uid b/addons/gdUnit4/src/asserts/ValueProvider.gd.uid new file mode 100644 index 0000000..dfbb13b --- /dev/null +++ b/addons/gdUnit4/src/asserts/ValueProvider.gd.uid @@ -0,0 +1 @@ +uid://bldj7g7rd20x2 diff --git a/addons/gdUnit4/src/cmd/CmdArgumentParser.gd b/addons/gdUnit4/src/cmd/CmdArgumentParser.gd new file mode 100644 index 0000000..aa02319 --- /dev/null +++ b/addons/gdUnit4/src/cmd/CmdArgumentParser.gd @@ -0,0 +1,62 @@ +class_name CmdArgumentParser +extends RefCounted + +var _options :CmdOptions +var _tool_name :String +var _parsed_commands :Dictionary = Dictionary() + + +func _init(p_options :CmdOptions, p_tool_name :String) -> void: + _options = p_options + _tool_name = p_tool_name + + +func parse(args :Array, ignore_unknown_cmd := false) -> GdUnitResult: + _parsed_commands.clear() + + # parse until first program argument + while not args.is_empty(): + var arg :String = args.pop_front() + if arg.find(_tool_name) != -1: + break + + if args.is_empty(): + return GdUnitResult.empty() + + # now parse all arguments + while not args.is_empty(): + var cmd :String = args.pop_front() + var option := _options.get_option(cmd) + + if option: + if _parse_cmd_arguments(option, args) == -1: + return GdUnitResult.error("The '%s' command requires an argument!" % option.short_command()) + elif not ignore_unknown_cmd: + return GdUnitResult.error("Unknown '%s' command!" % cmd) + return GdUnitResult.success(_parsed_commands.values()) + + +func options() -> CmdOptions: + return _options + + +func _parse_cmd_arguments(option: CmdOption, args: Array) -> int: + var command_name := option.short_command() + var command: CmdCommand = _parsed_commands.get(command_name, CmdCommand.new(command_name)) + + if option.has_argument(): + if not option.is_argument_optional() and args.is_empty(): + return -1 + if _is_next_value_argument(args): + var value: String = args.pop_front() + command.add_argument(value) + elif not option.is_argument_optional(): + return -1 + _parsed_commands[command_name] = command + return 0 + + +func _is_next_value_argument(args: PackedStringArray) -> bool: + if args.is_empty(): + return false + return _options.get_option(args[0]) == null diff --git a/addons/gdUnit4/src/cmd/CmdArgumentParser.gd.uid b/addons/gdUnit4/src/cmd/CmdArgumentParser.gd.uid new file mode 100644 index 0000000..242fbd7 --- /dev/null +++ b/addons/gdUnit4/src/cmd/CmdArgumentParser.gd.uid @@ -0,0 +1 @@ +uid://dhcvfamn1agov diff --git a/addons/gdUnit4/src/cmd/CmdCommand.gd b/addons/gdUnit4/src/cmd/CmdCommand.gd new file mode 100644 index 0000000..92e8c1f --- /dev/null +++ b/addons/gdUnit4/src/cmd/CmdCommand.gd @@ -0,0 +1,27 @@ +class_name CmdCommand +extends RefCounted + +var _name: String +var _arguments: PackedStringArray + + +func _init(p_name :String, p_arguments := []) -> void: + _name = p_name + _arguments = PackedStringArray(p_arguments) + + +func name() -> String: + return _name + + +func arguments() -> PackedStringArray: + return _arguments + + +func add_argument(arg :String) -> void: + @warning_ignore("return_value_discarded") + _arguments.append(arg) + + +func _to_string() -> String: + return "%s:%s" % [_name, ", ".join(_arguments)] diff --git a/addons/gdUnit4/src/cmd/CmdCommand.gd.uid b/addons/gdUnit4/src/cmd/CmdCommand.gd.uid new file mode 100644 index 0000000..d42006c --- /dev/null +++ b/addons/gdUnit4/src/cmd/CmdCommand.gd.uid @@ -0,0 +1 @@ +uid://dvprgqym5qliw diff --git a/addons/gdUnit4/src/cmd/CmdCommandHandler.gd b/addons/gdUnit4/src/cmd/CmdCommandHandler.gd new file mode 100644 index 0000000..2a7ed55 --- /dev/null +++ b/addons/gdUnit4/src/cmd/CmdCommandHandler.gd @@ -0,0 +1,136 @@ +class_name CmdCommandHandler +extends RefCounted + +const CB_SINGLE_ARG = 0 +const CB_MULTI_ARGS = 1 +const NO_CB := Callable() + +var _cmd_options :CmdOptions +# holds the command callbacks by key::String and value: [, ]:Array +# Dictionary[String, Array[Callback] +var _command_cbs :Dictionary + + + +func _init(cmd_options: CmdOptions) -> void: + _cmd_options = cmd_options + + +# register a callback function for given command +# cmd_name short name of the command +# fr_arg a funcref to a function with a single argument +func register_cb(cmd_name: String, cb: Callable) -> CmdCommandHandler: + var registered_cb: Array = _command_cbs.get(cmd_name, [NO_CB, NO_CB]) + if registered_cb[CB_SINGLE_ARG]: + push_error("A function for command '%s' is already registered!" % cmd_name) + return self + + if not _validate_cb_signature(cb, TYPE_STRING): + push_error( + ("The callback '%s:%s' for command '%s' has invalid function signature. " + +"The callback signature must be 'func name(value: PackedStringArray)'") + % [cb.get_object().get_class(), cb.get_method(), cmd_name]) + return null + + registered_cb[CB_SINGLE_ARG] = cb + _command_cbs[cmd_name] = registered_cb + return self + + +# register a callback function for given command +# cb a funcref to a function with a variable number of arguments but expects all parameters to be passed via a single Array. +func register_cbv(cmd_name: String, cb: Callable) -> CmdCommandHandler: + var registered_cb: Array = _command_cbs.get(cmd_name, [NO_CB, NO_CB]) + if registered_cb[CB_MULTI_ARGS]: + push_error("A function for command '%s' is already registered!" % cmd_name) + return self + + if not _validate_cb_signature(cb, TYPE_PACKED_STRING_ARRAY): + push_error( + ("The callback '%s:%s' for command '%s' has invalid function signature. " + +"The callback signature must be 'func name(value: PackedStringArray)'") + % [cb.get_object().get_class(), cb.get_method(), cmd_name]) + return null + + registered_cb[CB_MULTI_ARGS] = cb + _command_cbs[cmd_name] = registered_cb + return self + + +func _validate() -> GdUnitResult: + var errors := PackedStringArray() + # Dictionary[StringName, String] + var registered_cbs := Dictionary() + + for cmd_name in _command_cbs.keys() as Array[String]: + var cb: Callable = (_command_cbs[cmd_name][CB_SINGLE_ARG] + if _command_cbs[cmd_name][CB_SINGLE_ARG] + else _command_cbs[cmd_name][CB_MULTI_ARGS]) + if cb != NO_CB and not cb.is_valid(): + @warning_ignore("return_value_discarded") + errors.append("Invalid function reference for command '%s', Check the function reference!" % cmd_name) + if _cmd_options.get_option(cmd_name) == null: + @warning_ignore("return_value_discarded") + errors.append("The command '%s' is unknown, verify your CmdOptions!" % cmd_name) + # verify for multiple registered command callbacks + if cb != NO_CB: + var cb_method := cb.get_method() + if registered_cbs.has(cb_method): + var already_registered_cmd :String = registered_cbs[cb_method] + @warning_ignore("return_value_discarded") + errors.append("The function reference '%s' already registerd for command '%s'!" % [cb_method, already_registered_cmd]) + else: + registered_cbs[cb_method] = cmd_name + if errors.is_empty(): + return GdUnitResult.success(true) + return GdUnitResult.error("\n".join(errors)) + + +func execute(commands: Array[CmdCommand]) -> GdUnitResult: + var result := _validate() + if result.is_error(): + return result + for cmd in commands: + var cmd_name := cmd.name() + if _command_cbs.has(cmd_name): + var cb_s: Callable = _command_cbs.get(cmd_name)[CB_SINGLE_ARG] + var arguments := cmd.arguments() + var cmd_option := _cmd_options.get_option(cmd_name) + + if arguments.is_empty(): + cb_s.call() + elif arguments.size() > 1: + var cb_m: Callable = _command_cbs.get(cmd_name)[CB_MULTI_ARGS] + cb_m.call(arguments) + else: + if cmd_option.type() == TYPE_BOOL: + cb_s.call(true if arguments[0] == "true" else false) + else: + cb_s.call(arguments[0]) + + return GdUnitResult.success(true) + + +func _validate_cb_signature(cb: Callable, arg_type: int) -> bool: + for m in cb.get_object().get_method_list(): + if m["name"] == cb.get_method(): + @warning_ignore("unsafe_cast") + return _validate_func_arguments(m["args"] as Array, arg_type) + return true + + +func _validate_func_arguments(arguments: Array, arg_type: int) -> bool: + # validate we have a single argument + if arguments.size() > 1: + return false + # a cb with no arguments is also valid + if arguments.size() == 0: + return true + # validate argument type + var arg: Dictionary = arguments[0] + @warning_ignore("unsafe_cast") + if arg["usage"] as int == PROPERTY_USAGE_NIL_IS_VARIANT: + return true + if arg["type"] != arg_type: + return false + return true diff --git a/addons/gdUnit4/src/cmd/CmdCommandHandler.gd.uid b/addons/gdUnit4/src/cmd/CmdCommandHandler.gd.uid new file mode 100644 index 0000000..93d3f6f --- /dev/null +++ b/addons/gdUnit4/src/cmd/CmdCommandHandler.gd.uid @@ -0,0 +1 @@ +uid://8pkhtlobos7b diff --git a/addons/gdUnit4/src/cmd/CmdOption.gd b/addons/gdUnit4/src/cmd/CmdOption.gd new file mode 100644 index 0000000..a4982de --- /dev/null +++ b/addons/gdUnit4/src/cmd/CmdOption.gd @@ -0,0 +1,61 @@ +class_name CmdOption +extends RefCounted + + +var _commands :PackedStringArray +var _help :String +var _description :String +var _type :int +var _arg_optional :bool = false + + +# constructs a command option by given arguments +# commands : a string with comma separated list of available commands begining with the short form +# help: a help text show howto use +# description: a full description of the command +# type: the argument type +# arg_optional: defines of the argument optional +func _init(p_commands :String, p_help :String, p_description :String, p_type :int = TYPE_NIL, p_arg_optional :bool = false) -> void: + _commands = p_commands.replace(" ", "").replace("\t", "").split(",") + _help = p_help + _description = p_description + _type = p_type + _arg_optional = p_arg_optional + + +func commands() -> PackedStringArray: + return _commands + + +func short_command() -> String: + return _commands[0] + + +func help() -> String: + return _help + + +func description() -> String: + return _description + + +func type() -> int: + return _type + + +func is_argument_optional() -> bool: + return _arg_optional + + +func has_argument() -> bool: + return _type != TYPE_NIL + + +func describe() -> String: + if help().is_empty(): + return " %-32s %s \n" % [commands(), description()] + return " %-32s %s \n %-32s %s\n" % [commands(), description(), "", help()] + + +func _to_string() -> String: + return describe() diff --git a/addons/gdUnit4/src/cmd/CmdOption.gd.uid b/addons/gdUnit4/src/cmd/CmdOption.gd.uid new file mode 100644 index 0000000..b0e2e80 --- /dev/null +++ b/addons/gdUnit4/src/cmd/CmdOption.gd.uid @@ -0,0 +1 @@ +uid://chcdgq6lbfejk diff --git a/addons/gdUnit4/src/cmd/CmdOptions.gd b/addons/gdUnit4/src/cmd/CmdOptions.gd new file mode 100644 index 0000000..c610529 --- /dev/null +++ b/addons/gdUnit4/src/cmd/CmdOptions.gd @@ -0,0 +1,31 @@ +class_name CmdOptions +extends RefCounted + + +var _default_options :Array[CmdOption] +var _advanced_options :Array[CmdOption] + + +func _init(p_options :Array[CmdOption] = [], p_advanced_options :Array[CmdOption] = []) -> void: + # default help options + _default_options = p_options + _advanced_options = p_advanced_options + + +func default_options() -> Array[CmdOption]: + return _default_options + + +func advanced_options() -> Array[CmdOption]: + return _advanced_options + + +func options() -> Array[CmdOption]: + return default_options() + advanced_options() + + +func get_option(cmd :String) -> CmdOption: + for option in options(): + if Array(option.commands()).has(cmd): + return option + return null diff --git a/addons/gdUnit4/src/cmd/CmdOptions.gd.uid b/addons/gdUnit4/src/cmd/CmdOptions.gd.uid new file mode 100644 index 0000000..1052a45 --- /dev/null +++ b/addons/gdUnit4/src/cmd/CmdOptions.gd.uid @@ -0,0 +1 @@ +uid://bgob4oq35i7l7 diff --git a/addons/gdUnit4/src/core/GdArrayTools.gd b/addons/gdUnit4/src/core/GdArrayTools.gd new file mode 100644 index 0000000..74f0e17 --- /dev/null +++ b/addons/gdUnit4/src/core/GdArrayTools.gd @@ -0,0 +1,127 @@ +## Small helper tool to work with Godot Arrays +class_name GdArrayTools +extends RefCounted + + +const max_elements := 32 +const ARRAY_TYPES := [ + TYPE_ARRAY, + TYPE_PACKED_BYTE_ARRAY, + TYPE_PACKED_INT32_ARRAY, + TYPE_PACKED_INT64_ARRAY, + TYPE_PACKED_FLOAT32_ARRAY, + TYPE_PACKED_FLOAT64_ARRAY, + TYPE_PACKED_STRING_ARRAY, + TYPE_PACKED_VECTOR2_ARRAY, + TYPE_PACKED_VECTOR3_ARRAY, + TYPE_PACKED_VECTOR4_ARRAY, + TYPE_PACKED_COLOR_ARRAY +] + + +static func is_array_type(value: Variant) -> bool: + return is_type_array(typeof(value)) + + +static func is_type_array(type :int) -> bool: + return type in ARRAY_TYPES + + +## Filters an array by given value[br] +## If the given value not an array it returns null, will remove all occurence of given value. +static func filter_value(array: Variant, value: Variant) -> Variant: + if not is_array_type(array): + return null + + @warning_ignore("unsafe_method_access") + var filtered_array: Variant = array.duplicate() + @warning_ignore("unsafe_method_access") + var index: int = filtered_array.find(value) + while index != -1: + @warning_ignore("unsafe_method_access") + filtered_array.remove_at(index) + @warning_ignore("unsafe_method_access") + index = filtered_array.find(value) + return filtered_array + + +## Groups an array by a custom key selector +## The function should take an item and return the group key +static func group_by(array: Array, key_selector: Callable) -> Dictionary: + var result := {} + + for item: Variant in array: + var group_key: Variant = key_selector.call(item) + var values: Array = result.get_or_add(group_key, []) + values.append(item) + + return result + + +## Erases a value from given array by using equals(l,r) to find the element to erase +static func erase_value(array :Array, value :Variant) -> void: + for element :Variant in array: + if GdObjects.equals(element, value): + array.erase(element) + + +## Scans for the array build in type on a untyped array[br] +## Returns the buildin type by scan all values and returns the type if all values has the same type. +## If the values has different types TYPE_VARIANT is returend +static func scan_typed(array :Array) -> int: + if array.is_empty(): + return TYPE_NIL + var actual_type := GdObjects.TYPE_VARIANT + for value :Variant in array: + var current_type := typeof(value) + if not actual_type in [GdObjects.TYPE_VARIANT, current_type]: + return GdObjects.TYPE_VARIANT + actual_type = current_type + return actual_type + + +## Converts given array into a string presentation.[br] +## This function is different to the original Godot str() implementation. +## The string presentaion contains fullquallified typed informations. +##[br] +## Examples: +## [codeblock] +## # will result in PackedString(["a", "b"]) +## GdArrayTools.as_string(PackedStringArray("a", "b")) +## # will result in PackedString(["a", "b"]) +## GdArrayTools.as_string(PackedColorArray(Color.RED, COLOR.GREEN)) +## [/codeblock] +static func as_string(elements: Variant, encode_value := true) -> String: + var delemiter := ", " + if elements == null: + return "" + @warning_ignore("unsafe_cast") + if (elements as Array).is_empty(): + return "" + var prefix := _typeof_as_string(elements) if encode_value else "" + var formatted := "" + var index := 0 + for element :Variant in elements: + if max_elements != -1 and index > max_elements: + return prefix + "[" + formatted + delemiter + "...]" + if formatted.length() > 0 : + formatted += delemiter + formatted += GdDefaultValueDecoder.decode(element) if encode_value else str(element) + index += 1 + return prefix + "[" + formatted + "]" + + +static func has_same_content(current: Array, other: Array) -> bool: + if current.size() != other.size(): return false + for element: Variant in current: + if not other.has(element): return false + if current.count(element) != other.count(element): return false + return true + + +static func _typeof_as_string(value :Variant) -> String: + var type := typeof(value) + # for untyped array we retun empty string + if type == TYPE_ARRAY: + return "" + return GdObjects.typeof_as_string(value) diff --git a/addons/gdUnit4/src/core/GdArrayTools.gd.uid b/addons/gdUnit4/src/core/GdArrayTools.gd.uid new file mode 100644 index 0000000..025ee3e --- /dev/null +++ b/addons/gdUnit4/src/core/GdArrayTools.gd.uid @@ -0,0 +1 @@ +uid://djoe4mjdgkroc diff --git a/addons/gdUnit4/src/core/GdDiffTool.gd b/addons/gdUnit4/src/core/GdDiffTool.gd new file mode 100644 index 0000000..5131df7 --- /dev/null +++ b/addons/gdUnit4/src/core/GdDiffTool.gd @@ -0,0 +1,224 @@ +# Myers' Diff Algorithm implementation +# Based on "An O(ND) Difference Algorithm and Its Variations" by Eugene W. Myers +class_name GdDiffTool +extends RefCounted + + +const DIV_ADD :int = 214 +const DIV_SUB :int = 215 + + +class Edit: + enum Type { EQUAL, INSERT, DELETE } + var type: Type + var character: int + + func _init(t: Type, chr: int) -> void: + type = t + character = chr + + +# Main entry point - returns [ldiff, rdiff] +static func string_diff(left: Variant, right: Variant) -> Array[PackedInt32Array]: + var lb := PackedInt32Array() if left == null else str(left).to_utf32_buffer().to_int32_array() + var rb := PackedInt32Array() if right == null else str(right).to_utf32_buffer().to_int32_array() + + # Early exit for identical strings + if lb == rb: + return [lb.duplicate(), rb.duplicate()] + + var edits := _myers_diff(lb, rb) + return _edits_to_diff_format(edits) + + +# Core Myers' algorithm +static func _myers_diff(a: PackedInt32Array, b: PackedInt32Array) -> Array[Edit]: + var n := a.size() + var m := b.size() + var max_d := n + m + + # V array stores the furthest reaching x coordinate for each k-line + # We need indices from -max_d to max_d, so we offset by max_d + var v := PackedInt32Array() + v.resize(2 * max_d + 1) + v.fill(-1) + v[max_d + 1] = 0 # k=1 starts at x=0 + + var trace := [] # Store V arrays for each d to backtrack later + + # Find the edit distance + for d in range(0, max_d + 1): + # Store current V for backtracking + trace.append(v.duplicate()) + + for k in range(-d, d + 1, 2): + var k_offset := k + max_d + + # Decide whether to move down or right + var x: int + if k == -d or (k != d and v[k_offset - 1] < v[k_offset + 1]): + x = v[k_offset + 1] # Move down (insert from b) + else: + x = v[k_offset - 1] + 1 # Move right (delete from a) + + var y := x - k + + # Follow diagonal as far as possible (matching characters) + while x < n and y < m and a[x] == b[y]: + x += 1 + y += 1 + + v[k_offset] = x + + # Check if we've reached the end + if x >= n and y >= m: + return _backtrack(a, b, trace, d, max_d) + + # Should never reach here for valid inputs + return [] + + +# Backtrack through the edit graph to build the edit script +static func _backtrack(a: PackedInt32Array, b: PackedInt32Array, trace: Array, d: int, max_d: int) -> Array[Edit]: + var edits: Array[Edit] = [] + var x := a.size() + var y := b.size() + + # Walk backwards through each d value + for depth in range(d, -1, -1): + var v: PackedInt32Array = trace[depth] + var k := x - y + var k_offset := k + max_d + + # Determine previous k + var prev_k: int + if k == -depth or (k != depth and v[k_offset - 1] < v[k_offset + 1]): + prev_k = k + 1 + else: + prev_k = k - 1 + + var prev_k_offset := prev_k + max_d + var prev_x := v[prev_k_offset] + var prev_y := prev_x - prev_k + + # Extract diagonal (equal) characters + while x > prev_x and y > prev_y: + x -= 1 + y -= 1 + #var char_array := PackedInt32Array([a[x]]) + edits.insert(0, Edit.new(Edit.Type.EQUAL, a[x])) + + # Record the edit operation + if depth > 0: + if x == prev_x: + # Insert from b + y -= 1 + #var char_array := PackedInt32Array([b[y]]) + edits.insert(0, Edit.new(Edit.Type.INSERT, b[y])) + else: + # Delete from a + x -= 1 + #var char_array := PackedInt32Array([a[x]]) + edits.insert(0, Edit.new(Edit.Type.DELETE, a[x])) + + return edits + + +# Convert edit script to the DIV_ADD/DIV_SUB format +static func _edits_to_diff_format(edits: Array[Edit]) -> Array[PackedInt32Array]: + var ldiff := PackedInt32Array() + var rdiff := PackedInt32Array() + + for edit in edits: + match edit.type: + Edit.Type.EQUAL: + ldiff.append(edit.character) + rdiff.append(edit.character) + Edit.Type.INSERT: + ldiff.append(DIV_ADD) + ldiff.append(edit.character) + rdiff.append(DIV_SUB) + rdiff.append(edit.character) + Edit.Type.DELETE: + ldiff.append(DIV_SUB) + ldiff.append(edit.character) + rdiff.append(DIV_ADD) + rdiff.append(edit.character) + + return [ldiff, rdiff] + + +# prototype +static func longestCommonSubsequence(text1 :String, text2 :String) -> PackedStringArray: + var text1Words := text1.split(" ") + var text2Words := text2.split(" ") + var text1WordCount := text1Words.size() + var text2WordCount := text2Words.size() + var solutionMatrix := Array() + for i in text1WordCount+1: + var ar := Array() + for n in text2WordCount+1: + ar.append(0) + solutionMatrix.append(ar) + + for i in range(text1WordCount-1, 0, -1): + for j in range(text2WordCount-1, 0, -1): + if text1Words[i] == text2Words[j]: + solutionMatrix[i][j] = solutionMatrix[i + 1][j + 1] + 1; + else: + solutionMatrix[i][j] = max(solutionMatrix[i + 1][j], solutionMatrix[i][j + 1]); + + var i := 0 + var j := 0 + var lcsResultList := PackedStringArray(); + while (i < text1WordCount && j < text2WordCount): + if text1Words[i] == text2Words[j]: + @warning_ignore("return_value_discarded") + lcsResultList.append(text2Words[j]) + i += 1 + j += 1 + else: if (solutionMatrix[i + 1][j] >= solutionMatrix[i][j + 1]): + i += 1 + else: + j += 1 + return lcsResultList + + +static func markTextDifferences(text1 :String, text2 :String, lcsList :PackedStringArray, insertColor :Color, deleteColor:Color) -> String: + var stringBuffer := "" + if text1 == null and lcsList == null: + return stringBuffer + + var text1Words := text1.split(" ") + var text2Words := text2.split(" ") + var i := 0 + var j := 0 + var word1LastIndex := 0 + var word2LastIndex := 0 + for k in lcsList.size(): + while i < text1Words.size() and j < text2Words.size(): + if text1Words[i] == lcsList[k] and text2Words[j] == lcsList[k]: + stringBuffer += "" + lcsList[k] + " " + word1LastIndex = i + 1 + word2LastIndex = j + 1 + i = text1Words.size() + j = text2Words.size() + + else: if text1Words[i] != lcsList[k]: + while i < text1Words.size() and text1Words[i] != lcsList[k]: + stringBuffer += "" + text1Words[i] + " " + i += 1 + else: if text2Words[j] != lcsList[k]: + while j < text2Words.size() and text2Words[j] != lcsList[k]: + stringBuffer += "" + text2Words[j] + " " + j += 1 + i = word1LastIndex + j = word2LastIndex + + while word1LastIndex < text1Words.size(): + stringBuffer += "" + text1Words[word1LastIndex] + " " + word1LastIndex += 1 + while word2LastIndex < text2Words.size(): + stringBuffer += "" + text2Words[word2LastIndex] + " " + word2LastIndex += 1 + return stringBuffer diff --git a/addons/gdUnit4/src/core/GdDiffTool.gd.uid b/addons/gdUnit4/src/core/GdDiffTool.gd.uid new file mode 100644 index 0000000..a7b283f --- /dev/null +++ b/addons/gdUnit4/src/core/GdDiffTool.gd.uid @@ -0,0 +1 @@ +uid://bjrc7s5udmpfu diff --git a/addons/gdUnit4/src/core/GdObjects.gd b/addons/gdUnit4/src/core/GdObjects.gd new file mode 100644 index 0000000..66daea8 --- /dev/null +++ b/addons/gdUnit4/src/core/GdObjects.gd @@ -0,0 +1,726 @@ +# This is a helper class to compare two objects by equals +class_name GdObjects +extends Resource + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + + +# introduced with Godot 4.3.beta1 +const TYPE_VOID = 1000 +const TYPE_VARARG = 1001 +const TYPE_VARIANT = 1002 +const TYPE_FUNC = 1003 +const TYPE_FUZZER = 1004 +# missing Godot types +const TYPE_NODE = 2001 +const TYPE_CONTROL = 2002 +const TYPE_CANVAS = 2003 +const TYPE_ENUM = 2004 + + +const TYPE_AS_STRING_MAPPINGS := { + TYPE_NIL: "null", + TYPE_BOOL: "bool", + TYPE_INT: "int", + TYPE_FLOAT: "float", + TYPE_STRING: "String", + TYPE_VECTOR2: "Vector2", + TYPE_VECTOR2I: "Vector2i", + TYPE_RECT2: "Rect2", + TYPE_RECT2I: "Rect2i", + TYPE_VECTOR3: "Vector3", + TYPE_VECTOR3I: "Vector3i", + TYPE_TRANSFORM2D: "Transform2D", + TYPE_VECTOR4: "Vector4", + TYPE_VECTOR4I: "Vector4i", + TYPE_PLANE: "Plane", + TYPE_QUATERNION: "Quaternion", + TYPE_AABB: "AABB", + TYPE_BASIS: "Basis", + TYPE_TRANSFORM3D: "Transform3D", + TYPE_PROJECTION: "Projection", + TYPE_COLOR: "Color", + TYPE_STRING_NAME: "StringName", + TYPE_NODE_PATH: "NodePath", + TYPE_RID: "RID", + TYPE_OBJECT: "Object", + TYPE_CALLABLE: "Callable", + TYPE_SIGNAL: "Signal", + TYPE_DICTIONARY: "Dictionary", + TYPE_ARRAY: "Array", + TYPE_PACKED_BYTE_ARRAY: "PackedByteArray", + TYPE_PACKED_INT32_ARRAY: "PackedInt32Array", + TYPE_PACKED_INT64_ARRAY: "PackedInt64Array", + TYPE_PACKED_FLOAT32_ARRAY: "PackedFloat32Array", + TYPE_PACKED_FLOAT64_ARRAY: "PackedFloat64Array", + TYPE_PACKED_STRING_ARRAY: "PackedStringArray", + TYPE_PACKED_VECTOR2_ARRAY: "PackedVector2Array", + TYPE_PACKED_VECTOR3_ARRAY: "PackedVector3Array", + TYPE_PACKED_VECTOR4_ARRAY: "PackedVector4Array", + TYPE_PACKED_COLOR_ARRAY: "PackedColorArray", + TYPE_VOID: "void", + TYPE_VARARG: "VarArg", + TYPE_FUNC: "Func", + TYPE_FUZZER: "Fuzzer", + TYPE_VARIANT: "Variant" +} + + +class EditorNotifications: + # NOTE: Hardcoding to avoid runtime errors in exported projects when editor + # classes are not available. These values are unlikely to change. + # See: EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED + const NOTIFICATION_EDITOR_SETTINGS_CHANGED := 10000 + + +const NOTIFICATION_AS_STRING_MAPPINGS := { + TYPE_OBJECT: { + Object.NOTIFICATION_POSTINITIALIZE : "POSTINITIALIZE", + Object.NOTIFICATION_PREDELETE: "PREDELETE", + EditorNotifications.NOTIFICATION_EDITOR_SETTINGS_CHANGED: "EDITOR_SETTINGS_CHANGED", + }, + TYPE_NODE: { + Node.NOTIFICATION_ENTER_TREE : "ENTER_TREE", + Node.NOTIFICATION_EXIT_TREE: "EXIT_TREE", + Node.NOTIFICATION_CHILD_ORDER_CHANGED: "CHILD_ORDER_CHANGED", + Node.NOTIFICATION_READY: "READY", + Node.NOTIFICATION_PAUSED: "PAUSED", + Node.NOTIFICATION_UNPAUSED: "UNPAUSED", + Node.NOTIFICATION_PHYSICS_PROCESS: "PHYSICS_PROCESS", + Node.NOTIFICATION_PROCESS: "PROCESS", + Node.NOTIFICATION_PARENTED: "PARENTED", + Node.NOTIFICATION_UNPARENTED: "UNPARENTED", + Node.NOTIFICATION_SCENE_INSTANTIATED: "INSTANCED", + Node.NOTIFICATION_DRAG_BEGIN: "DRAG_BEGIN", + Node.NOTIFICATION_DRAG_END: "DRAG_END", + Node.NOTIFICATION_PATH_RENAMED: "PATH_CHANGED", + Node.NOTIFICATION_INTERNAL_PROCESS: "INTERNAL_PROCESS", + Node.NOTIFICATION_INTERNAL_PHYSICS_PROCESS: "INTERNAL_PHYSICS_PROCESS", + Node.NOTIFICATION_POST_ENTER_TREE: "POST_ENTER_TREE", + Node.NOTIFICATION_WM_MOUSE_ENTER: "WM_MOUSE_ENTER", + Node.NOTIFICATION_WM_MOUSE_EXIT: "WM_MOUSE_EXIT", + Node.NOTIFICATION_APPLICATION_FOCUS_IN: "WM_FOCUS_IN", + Node.NOTIFICATION_APPLICATION_FOCUS_OUT: "WM_FOCUS_OUT", + #Node.NOTIFICATION_WM_QUIT_REQUEST: "WM_QUIT_REQUEST", + Node.NOTIFICATION_WM_GO_BACK_REQUEST: "WM_GO_BACK_REQUEST", + Node.NOTIFICATION_WM_WINDOW_FOCUS_OUT: "WM_UNFOCUS_REQUEST", + Node.NOTIFICATION_OS_MEMORY_WARNING: "OS_MEMORY_WARNING", + Node.NOTIFICATION_TRANSLATION_CHANGED: "TRANSLATION_CHANGED", + Node.NOTIFICATION_WM_ABOUT: "WM_ABOUT", + Node.NOTIFICATION_CRASH: "CRASH", + Node.NOTIFICATION_OS_IME_UPDATE: "OS_IME_UPDATE", + Node.NOTIFICATION_APPLICATION_RESUMED: "APP_RESUMED", + Node.NOTIFICATION_APPLICATION_PAUSED: "APP_PAUSED", + Node3D.NOTIFICATION_TRANSFORM_CHANGED: "TRANSFORM_CHANGED", + Node3D.NOTIFICATION_ENTER_WORLD: "ENTER_WORLD", + Node3D.NOTIFICATION_EXIT_WORLD: "EXIT_WORLD", + Node3D.NOTIFICATION_VISIBILITY_CHANGED: "VISIBILITY_CHANGED", + Skeleton3D.NOTIFICATION_UPDATE_SKELETON: "UPDATE_SKELETON", + CanvasItem.NOTIFICATION_DRAW: "DRAW", + CanvasItem.NOTIFICATION_VISIBILITY_CHANGED: "VISIBILITY_CHANGED", + CanvasItem.NOTIFICATION_ENTER_CANVAS: "ENTER_CANVAS", + CanvasItem.NOTIFICATION_EXIT_CANVAS: "EXIT_CANVAS", + #Popup.NOTIFICATION_POST_POPUP: "POST_POPUP", + #Popup.NOTIFICATION_POPUP_HIDE: "POPUP_HIDE", + }, + TYPE_CONTROL : { + Object.NOTIFICATION_PREDELETE: "PREDELETE", + Container.NOTIFICATION_SORT_CHILDREN: "SORT_CHILDREN", + Control.NOTIFICATION_RESIZED: "RESIZED", + Control.NOTIFICATION_MOUSE_ENTER: "MOUSE_ENTER", + Control.NOTIFICATION_MOUSE_EXIT: "MOUSE_EXIT", + Control.NOTIFICATION_FOCUS_ENTER: "FOCUS_ENTER", + Control.NOTIFICATION_FOCUS_EXIT: "FOCUS_EXIT", + Control.NOTIFICATION_THEME_CHANGED: "THEME_CHANGED", + #Control.NOTIFICATION_MODAL_CLOSE: "MODAL_CLOSE", + Control.NOTIFICATION_SCROLL_BEGIN: "SCROLL_BEGIN", + Control.NOTIFICATION_SCROLL_END: "SCROLL_END", + } +} + + +enum COMPARE_MODE { + OBJECT_REFERENCE, + PARAMETER_DEEP_TEST +} + + +# prototype of better object to dictionary +static func obj2dict(obj: Object, hashed_objects := Dictionary()) -> Dictionary: + if obj == null: + return {} + var clazz_name := obj.get_class() + var dict := Dictionary() + var clazz_path := "" + + if is_instance_valid(obj) and obj.get_script() != null: + var script: Script = obj.get_script() + # handle build-in scripts + if script.resource_path != null and script.resource_path.contains(".tscn"): + var path_elements := script.resource_path.split(".tscn") + clazz_name = path_elements[0].get_file() + clazz_path = script.resource_path + else: + var d := inst_to_dict(obj) + clazz_path = d["@path"] + if d["@subpath"] != NodePath(""): + clazz_name = d["@subpath"] + dict["@inner_class"] = true + else: + clazz_name = clazz_path.get_file().replace(".gd", "") + dict["@path"] = clazz_path + + for property in obj.get_property_list(): + var property_name :String = property["name"] + var property_type :int = property["type"] + var property_value :Variant = obj.get(property_name) + if property_value is GDScript or property_value is Callable or property_value is RegEx: + continue + if (property["usage"] & PROPERTY_USAGE_SCRIPT_VARIABLE|PROPERTY_USAGE_DEFAULT + and not property["usage"] & PROPERTY_USAGE_CATEGORY + and not property["usage"] == 0): + if property_type == TYPE_OBJECT: + # prevent recursion + if hashed_objects.has(obj): + dict[property_name] = str(property_value) + continue + hashed_objects[obj] = true + @warning_ignore("unsafe_cast") + dict[property_name] = obj2dict(property_value as Object, hashed_objects) + else: + dict[property_name] = property_value + if obj is Node: + var childrens :Array = (obj as Node).get_children() + dict["childrens"] = childrens.map(func (child :Object) -> Dictionary: return obj2dict(child, hashed_objects)) + if obj is TreeItem: + var childrens :Array = (obj as TreeItem).get_children() + dict["childrens"] = childrens.map(func (child :Object) -> Dictionary: return obj2dict(child, hashed_objects)) + + return {"%s" % clazz_name : dict} + + +static func equals(obj_a: Variant, obj_b: Variant, case_sensitive: bool = true, compare_mode: COMPARE_MODE = COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool: + return _equals(obj_a, obj_b, case_sensitive, compare_mode, [], 0) + + +static func equals_sorted(obj_a: Array[Variant], obj_b: Array[Variant], case_sensitive: bool = true, compare_mode: COMPARE_MODE = COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool: + var a: Array[Variant] = obj_a.duplicate() + var b: Array[Variant] = obj_b.duplicate() + a.sort() + b.sort() + return equals(a, b, case_sensitive, compare_mode) + + +static func _equals(obj_a: Variant, obj_b: Variant, case_sensitive: bool, compare_mode: COMPARE_MODE, deep_stack: Array, stack_depth: int ) -> bool: + var type_a := typeof(obj_a) + var type_b := typeof(obj_b) + if stack_depth > 32: + prints("stack_depth", stack_depth, deep_stack) + push_error("GdUnit equals has max stack deep reached!") + return false + + # use argument matcher if requested + if is_instance_valid(obj_a) and obj_a is GdUnitArgumentMatcher: + @warning_ignore("unsafe_cast") + return (obj_a as GdUnitArgumentMatcher).is_match(obj_b) + if is_instance_valid(obj_b) and obj_b is GdUnitArgumentMatcher: + @warning_ignore("unsafe_cast") + return (obj_b as GdUnitArgumentMatcher).is_match(obj_a) + + stack_depth += 1 + # fast fail is different types + if not _is_type_equivalent(type_a, type_b): + return false + # is same instance + if obj_a == obj_b: + return true + # handle null values + if obj_a == null and obj_b != null: + return false + if obj_b == null and obj_a != null: + return false + + match type_a: + TYPE_OBJECT: + if deep_stack.has(obj_a) or deep_stack.has(obj_b): + return true + deep_stack.append(obj_a) + deep_stack.append(obj_b) + if compare_mode == COMPARE_MODE.PARAMETER_DEEP_TEST: + # fail fast + if not is_instance_valid(obj_a) or not is_instance_valid(obj_b): + return false + @warning_ignore("unsafe_method_access") + if obj_a.get_class() != obj_b.get_class(): + return false + @warning_ignore("unsafe_cast") + var a := obj2dict(obj_a as Object) + @warning_ignore("unsafe_cast") + var b := obj2dict(obj_b as Object) + return _equals(a, b, case_sensitive, compare_mode, deep_stack, stack_depth) + return obj_a == obj_b + + TYPE_ARRAY: + @warning_ignore("unsafe_method_access") + if obj_a.size() != obj_b.size(): + return false + @warning_ignore("unsafe_method_access") + for index :int in obj_a.size(): + if not _equals(obj_a[index], obj_b[index], case_sensitive, compare_mode, deep_stack, stack_depth): + return false + return true + + TYPE_DICTIONARY: + @warning_ignore("unsafe_method_access") + if obj_a.size() != obj_b.size(): + return false + @warning_ignore("unsafe_method_access") + for key :Variant in obj_a.keys(): + @warning_ignore("unsafe_method_access") + var value_a :Variant = obj_a[key] if obj_a.has(key) else null + @warning_ignore("unsafe_method_access") + var value_b :Variant = obj_b[key] if obj_b.has(key) else null + if not _equals(value_a, value_b, case_sensitive, compare_mode, deep_stack, stack_depth): + return false + return true + + TYPE_STRING: + if case_sensitive: + return obj_a == obj_b + else: + @warning_ignore("unsafe_method_access") + return obj_a.to_lower() == obj_b.to_lower() + return obj_a == obj_b + + +@warning_ignore("shadowed_variable_base_class") +static func notification_as_string(instance :Variant, notification :int) -> String: + var error := "Unknown notification: '%s' at instance: %s" % [notification, instance] + if instance is Node and NOTIFICATION_AS_STRING_MAPPINGS[TYPE_NODE].has(notification): + return NOTIFICATION_AS_STRING_MAPPINGS[TYPE_NODE].get(notification, error) + if instance is Control and NOTIFICATION_AS_STRING_MAPPINGS[TYPE_CONTROL].has(notification): + return NOTIFICATION_AS_STRING_MAPPINGS[TYPE_CONTROL].get(notification, error) + return NOTIFICATION_AS_STRING_MAPPINGS[TYPE_OBJECT].get(notification, error) + + +static func string_to_type(value :String) -> int: + for type :int in TYPE_AS_STRING_MAPPINGS.keys(): + if TYPE_AS_STRING_MAPPINGS.get(type) == value: + return type + return TYPE_NIL + + +static func to_camel_case(value :String) -> String: + var p := to_pascal_case(value) + if not p.is_empty(): + p[0] = p[0].to_lower() + return p + + +static func to_pascal_case(value :String) -> String: + return value.capitalize().replace(" ", "") + + +@warning_ignore("return_value_discarded") +static func to_snake_case(value :String) -> String: + var result := PackedStringArray() + for ch in value: + var lower_ch := ch.to_lower() + if ch != lower_ch and result.size() > 1: + result.append('_') + result.append(lower_ch) + return ''.join(result) + + +static func is_snake_case(value :String) -> bool: + for ch in value: + if ch == '_': + continue + if ch == ch.to_upper(): + return false + return true + + +static func type_as_string(type :int) -> String: + if type < TYPE_MAX: + return type_string(type) + return TYPE_AS_STRING_MAPPINGS.get(type, "Variant") + + +static func typeof_as_string(value :Variant) -> String: + return TYPE_AS_STRING_MAPPINGS.get(typeof(value), "Unknown type") + + +static func all_types() -> PackedInt32Array: + return PackedInt32Array(TYPE_AS_STRING_MAPPINGS.keys()) + + +static func string_as_typeof(type_name :String) -> int: + var type :Variant = TYPE_AS_STRING_MAPPINGS.find_key(type_name) + return type if type != null else TYPE_VARIANT + + +static func is_primitive_type(value :Variant) -> bool: + return typeof(value) in [TYPE_BOOL, TYPE_STRING, TYPE_STRING_NAME, TYPE_INT, TYPE_FLOAT] + + +static func _is_type_equivalent(type_a :int, type_b :int) -> bool: + # don't test for TYPE_STRING_NAME equivalenz + if type_a == TYPE_STRING_NAME or type_b == TYPE_STRING_NAME: + return true + if GdUnitSettings.is_strict_number_type_compare(): + return type_a == type_b + return ( + (type_a == TYPE_FLOAT and type_b == TYPE_INT) + or (type_a == TYPE_INT and type_b == TYPE_FLOAT) + or type_a == type_b) + + +static func is_engine_type(value :Variant) -> bool: + if value is GDScript or value is ScriptExtension: + return false + var obj: Object = value + if is_instance_valid(obj) and obj.has_method("is_class"): + return obj.is_class("GDScriptNativeClass") + return false + + +static func is_type(value :Variant) -> bool: + # is an build-in type + if typeof(value) != TYPE_OBJECT: + return false + # is a engine class type + if is_engine_type(value): + return true + # is a custom class type + @warning_ignore("unsafe_cast") + if value is GDScript and (value as GDScript).can_instantiate(): + return true + return false + + +static func _is_same(left :Variant, right :Variant) -> bool: + var left_type := -1 if left == null else typeof(left) + var right_type := -1 if right == null else typeof(right) + + # if typ different can't be the same + if left_type != right_type: + return false + if left_type == TYPE_OBJECT and right_type == TYPE_OBJECT: + @warning_ignore("unsafe_cast") + return (left as Object).get_instance_id() == (right as Object).get_instance_id() + return equals(left, right) + + +static func is_object(value :Variant) -> bool: + return typeof(value) == TYPE_OBJECT + + +static func is_script(value :Variant) -> bool: + return is_object(value) and value is Script + + +static func is_native_class(value :Variant) -> bool: + return is_object(value) and is_engine_type(value) + + +static func is_scene(value :Variant) -> bool: + return is_object(value) and value is PackedScene + + +static func is_scene_resource_path(value :Variant) -> bool: + @warning_ignore("unsafe_cast") + return value is String and (value as String).ends_with(".tscn") + + +static func is_singleton(value: Variant) -> bool: + if not is_instance_valid(value) or is_native_class(value): + return false + for name in Engine.get_singleton_list(): + @warning_ignore("unsafe_cast") + if (value as Object).is_class(name): + return true + return false + + +static func is_instance(value :Variant) -> bool: + if not is_instance_valid(value) or is_native_class(value): + return false + @warning_ignore("unsafe_cast") + if is_script(value) and (value as Script).get_instance_base_type() == "": + return true + if is_scene(value): + return true + @warning_ignore("unsafe_cast") + return not (value as Object).has_method('new') and not (value as Object).has_method('instance') + + +# only object form type Node and attached filename +static func is_instance_scene(instance :Variant) -> bool: + if instance is Node: + var node: Node = instance + return node.get_scene_file_path() != null and not node.get_scene_file_path().is_empty() + return false + + +static func can_be_instantiate(obj :Variant) -> bool: + if not obj or is_engine_type(obj): + return false + @warning_ignore("unsafe_cast") + return (obj as Object).has_method("new") + + +static func create_instance(clazz :Variant) -> GdUnitResult: + match typeof(clazz): + TYPE_OBJECT: + # test is given clazz already an instance + if is_instance(clazz): + return GdUnitResult.success(clazz) + @warning_ignore("unsafe_method_access") + return GdUnitResult.success(clazz.new()) + TYPE_STRING: + var clazz_name: String = clazz + if ClassDB.class_exists(clazz_name): + if Engine.has_singleton(clazz_name): + return GdUnitResult.error("Not allowed to create a instance for singelton '%s'." % clazz_name) + if not ClassDB.can_instantiate(clazz_name): + return GdUnitResult.error("Can't instance Engine class '%s'." % clazz_name) + return GdUnitResult.success(ClassDB.instantiate(clazz_name)) + else: + var clazz_path :String = extract_class_path(clazz_name)[0] + if not FileAccess.file_exists(clazz_path): + return GdUnitResult.error("Class '%s' not found." % clazz_name) + var script: GDScript = load(clazz_path) + if script != null: + return GdUnitResult.success(script.new()) + else: + return GdUnitResult.error("Can't create instance for '%s'." % clazz_name) + return GdUnitResult.error("Can't create instance for class '%s'." % str(clazz)) + + +## We do dispose 'GDScriptFunctionState' in a kacky style because the class is not visible anymore +static func dispose_function_state(func_state: Variant) -> void: + if func_state != null and str(func_state).contains("GDScriptFunctionState"): + @warning_ignore("unsafe_method_access") + func_state.completed.emit() + + +@warning_ignore("return_value_discarded") +static func extract_class_path(clazz :Variant) -> PackedStringArray: + var clazz_path := PackedStringArray() + if clazz is String: + @warning_ignore("unsafe_cast") + clazz_path.append(clazz as String) + return clazz_path + if is_instance(clazz): + # is instance a script instance? + var script: GDScript = clazz.script + if script != null: + return extract_class_path(script) + return clazz_path + + if clazz is GDScript: + var script: GDScript = clazz + if not script.resource_path.is_empty(): + clazz_path.append(script.resource_path) + return clazz_path + # if not found we go the expensive way and extract the path form the script by creating an instance + var arg_list := build_function_default_arguments(script, "_init") + var instance: Object = script.callv("new", arg_list) + var clazz_info := inst_to_dict(instance) + GdUnitTools.free_instance(instance) + @warning_ignore("unsafe_cast") + clazz_path.append(clazz_info["@path"] as String) + if clazz_info.has("@subpath"): + var sub_path :String = clazz_info["@subpath"] + if not sub_path.is_empty(): + var sub_paths := sub_path.split("/") + clazz_path += sub_paths + return clazz_path + return clazz_path + + +static func extract_class_name_from_class_path(clazz_path :PackedStringArray) -> String: + var base_clazz := clazz_path[0] + # return original class name if engine class + if ClassDB.class_exists(base_clazz): + return base_clazz + var clazz_name := to_pascal_case(base_clazz.get_basename().get_file()) + for path_index in range(1, clazz_path.size()): + clazz_name += "." + clazz_path[path_index] + return clazz_name + + +static func extract_class_name(clazz :Variant) -> GdUnitResult: + if clazz == null: + return GdUnitResult.error("Can't extract class name form a null value.") + + if is_instance(clazz): + # is instance a script instance? + var script: GDScript = clazz.script + if script != null: + return extract_class_name(script) + @warning_ignore("unsafe_cast") + return GdUnitResult.success((clazz as Object).get_class()) + + # extract name form full qualified class path + if clazz is String: + var clazz_name: String = clazz + if ClassDB.class_exists(clazz_name): + return GdUnitResult.success(clazz_name) + var source_script :GDScript = load(clazz_name) + clazz_name = GdScriptParser.new().get_class_name(source_script) + return GdUnitResult.success(to_pascal_case(clazz_name)) + + if is_primitive_type(clazz): + return GdUnitResult.error("Can't extract class name for an primitive '%s'" % type_as_string(typeof(clazz))) + + if is_script(clazz): + @warning_ignore("unsafe_cast") + if (clazz as Script).resource_path.is_empty(): + var class_path := extract_class_name_from_class_path(extract_class_path(clazz)) + return GdUnitResult.success(class_path); + return extract_class_name(clazz.resource_path) + + # need to create an instance for a class typ the extract the class name + @warning_ignore("unsafe_method_access") + var instance :Variant = clazz.new() + if instance == null: + return GdUnitResult.error("Can't create a instance for class '%s'" % str(clazz)) + var result := extract_class_name(instance) + @warning_ignore("return_value_discarded") + GdUnitTools.free_instance(instance) + return result + + +static func extract_inner_clazz_names(clazz_name :String, script_path :PackedStringArray) -> PackedStringArray: + var inner_classes := PackedStringArray() + + if ClassDB.class_exists(clazz_name): + return inner_classes + var script :GDScript = load(script_path[0]) + var map := script.get_script_constant_map() + for key :String in map.keys(): + var value :Variant = map.get(key) + if value is GDScript: + var class_path := extract_class_path(value) + @warning_ignore("return_value_discarded") + inner_classes.append(class_path[1]) + return inner_classes + + +static func extract_class_functions(clazz_name :String, script_path :PackedStringArray) -> Array: + if ClassDB.class_get_method_list(clazz_name): + return ClassDB.class_get_method_list(clazz_name) + + if not FileAccess.file_exists(script_path[0]): + return Array() + var script :GDScript = load(script_path[0]) + if script is GDScript: + # if inner class on class path we have to load the script from the script_constant_map + if script_path.size() == 2 and script_path[1] != "": + var inner_classes := script_path[1] + var map := script.get_script_constant_map() + script = map[inner_classes] + var clazz_functions :Array = script.get_method_list() + var base_clazz :String = script.get_instance_base_type() + if base_clazz: + return extract_class_functions(base_clazz, script_path) + return clazz_functions + return Array() + + +# scans all registert script classes for given +# if the class is public in the global space than return true otherwise false +# public class means the script class is defined by 'class_name ' +static func is_public_script_class(clazz_name :String) -> bool: + var script_classes:Array[Dictionary] = ProjectSettings.get_global_class_list() + for class_info in script_classes: + if class_info.has("class"): + if class_info["class"] == clazz_name: + return true + return false + + +static func build_function_default_arguments(script :GDScript, func_name :String) -> Array: + var arg_list := Array() + for func_sig in script.get_script_method_list(): + if func_sig["name"] == func_name: + var args :Array[Dictionary] = func_sig["args"] + for arg in args: + var value_type :int = arg["type"] + var default_value :Variant = default_value_by_type(value_type) + arg_list.append(default_value) + return arg_list + return arg_list + + +static func default_value_by_type(type :int) -> Variant: + assert(type < TYPE_MAX) + assert(type >= 0) + + match type: + TYPE_NIL: return null + TYPE_BOOL: return false + TYPE_INT: return 0 + TYPE_FLOAT: return 0.0 + TYPE_STRING: return "" + TYPE_VECTOR2: return Vector2.ZERO + TYPE_VECTOR2I: return Vector2i.ZERO + TYPE_VECTOR3: return Vector3.ZERO + TYPE_VECTOR3I: return Vector3i.ZERO + TYPE_VECTOR4: return Vector4.ZERO + TYPE_VECTOR4I: return Vector4i.ZERO + TYPE_RECT2: return Rect2() + TYPE_RECT2I: return Rect2i() + TYPE_TRANSFORM2D: return Transform2D() + TYPE_PLANE: return Plane() + TYPE_QUATERNION: return Quaternion() + TYPE_AABB: return AABB() + TYPE_BASIS: return Basis() + TYPE_TRANSFORM3D: return Transform3D() + TYPE_COLOR: return Color() + TYPE_NODE_PATH: return NodePath() + TYPE_RID: return RID() + TYPE_OBJECT: return null + TYPE_CALLABLE: return Callable() + TYPE_ARRAY: return [] + TYPE_DICTIONARY: return {} + TYPE_PACKED_BYTE_ARRAY: return PackedByteArray() + TYPE_PACKED_COLOR_ARRAY: return PackedColorArray() + TYPE_PACKED_INT32_ARRAY: return PackedInt32Array() + TYPE_PACKED_INT64_ARRAY: return PackedInt64Array() + TYPE_PACKED_FLOAT32_ARRAY: return PackedFloat32Array() + TYPE_PACKED_FLOAT64_ARRAY: return PackedFloat64Array() + TYPE_PACKED_STRING_ARRAY: return PackedStringArray() + TYPE_PACKED_VECTOR2_ARRAY: return PackedVector2Array() + TYPE_PACKED_VECTOR3_ARRAY: return PackedVector3Array() + + push_error("Can't determine a default value for type: '%s', Please create a Bug issue and attach the stacktrace please." % type) + return null + + +static func find_nodes_by_class(root: Node, cls: String, recursive: bool = false) -> Array[Node]: + if not recursive: + return _find_nodes_by_class_no_rec(root, cls) + return _find_nodes_by_class(root, cls) + + +static func _find_nodes_by_class_no_rec(parent: Node, cls: String) -> Array[Node]: + var result :Array[Node] = [] + for ch in parent.get_children(): + if ch.get_class() == cls: + result.append(ch) + return result + + +static func _find_nodes_by_class(root: Node, cls: String) -> Array[Node]: + var result :Array[Node] = [] + var stack :Array[Node] = [root] + while stack: + var node :Node = stack.pop_back() + if node.get_class() == cls: + result.append(node) + for ch in node.get_children(): + stack.push_back(ch) + return result diff --git a/addons/gdUnit4/src/core/GdObjects.gd.uid b/addons/gdUnit4/src/core/GdObjects.gd.uid new file mode 100644 index 0000000..acff09a --- /dev/null +++ b/addons/gdUnit4/src/core/GdObjects.gd.uid @@ -0,0 +1 @@ +uid://ohdcci6x7fo6 diff --git a/addons/gdUnit4/src/core/GdUnit4Version.gd b/addons/gdUnit4/src/core/GdUnit4Version.gd new file mode 100644 index 0000000..3e6a334 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnit4Version.gd @@ -0,0 +1,65 @@ +class_name GdUnit4Version +extends RefCounted + +const VERSION_PATTERN = "[center][color=#9887c4]gd[/color][color=#7a57d6]Unit[/color][color=#9887c4]4[/color] [color=#9887c4]${version}[/color][/center]" + +var _major :int +var _minor :int +var _patch :int + + +func _init(major :int, minor :int, patch :int) -> void: + _major = major + _minor = minor + _patch = patch + + +static func parse(value :String) -> GdUnit4Version: + var regex := RegEx.new() + @warning_ignore("return_value_discarded") + regex.compile("[a-zA-Z:,-]+") + var cleaned := regex.sub(value, "", true) + var parts := cleaned.split(".") + var major := parts[0].to_int() + var minor := parts[1].to_int() + var patch := parts[2].to_int() if parts.size() > 2 else 0 + return GdUnit4Version.new(major, minor, patch) + + +static func current() -> GdUnit4Version: + var config := ConfigFile.new() + @warning_ignore("return_value_discarded") + config.load('addons/gdUnit4/plugin.cfg') + @warning_ignore("unsafe_cast") + return parse(config.get_value('plugin', 'version') as String) + + +func equals(other :GdUnit4Version) -> bool: + return _major == other._major and _minor == other._minor and _patch == other._patch + + +func is_greater(other :GdUnit4Version) -> bool: + if _major > other._major: + return true + if _major == other._major and _minor > other._minor: + return true + return _major == other._major and _minor == other._minor and _patch > other._patch + + +static func init_version_label(label :Control) -> void: + var config := ConfigFile.new() + @warning_ignore("return_value_discarded") + config.load('addons/gdUnit4/plugin.cfg') + var version :String = config.get_value('plugin', 'version') + if label is RichTextLabel: + (label as RichTextLabel).text = VERSION_PATTERN.replace('${version}', version) + else: + (label as Label).text = "gdUnit4 " + version + + +func _to_string() -> String: + return "v%d.%d.%d" % [_major, _minor, _patch] + + +func documentation_version() -> String: + return "v%d.%d.x" % [_major, _minor] diff --git a/addons/gdUnit4/src/core/GdUnit4Version.gd.uid b/addons/gdUnit4/src/core/GdUnit4Version.gd.uid new file mode 100644 index 0000000..a1bd5fd --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnit4Version.gd.uid @@ -0,0 +1 @@ +uid://btge85bkr2t8c diff --git a/addons/gdUnit4/src/core/GdUnitFileAccess.gd b/addons/gdUnit4/src/core/GdUnitFileAccess.gd new file mode 100644 index 0000000..f4ba003 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitFileAccess.gd @@ -0,0 +1,232 @@ +class_name GdUnitFileAccess +extends RefCounted + +const GDUNIT_TEMP := "user://tmp" + + +static func current_dir() -> String: + return ProjectSettings.globalize_path("res://") + + +static func clear_tmp() -> void: + delete_directory(GDUNIT_TEMP) + + +# Creates a new file under +static func create_temp_file(relative_path :String, file_name :String, mode := FileAccess.WRITE) -> FileAccess: + var file_path := create_temp_dir(relative_path) + "/" + file_name + var file := FileAccess.open(file_path, mode) + if file == null: + push_error("Error creating temporary file at: %s, %s" % [file_path, error_string(FileAccess.get_open_error())]) + return file + + +static func temp_dir() -> String: + if not DirAccess.dir_exists_absolute(GDUNIT_TEMP): + @warning_ignore("return_value_discarded") + DirAccess.make_dir_recursive_absolute(GDUNIT_TEMP) + return GDUNIT_TEMP + + +static func create_temp_dir(folder_name :String) -> String: + var new_folder := temp_dir() + "/" + folder_name + if not DirAccess.dir_exists_absolute(new_folder): + @warning_ignore("return_value_discarded") + DirAccess.make_dir_recursive_absolute(new_folder) + return new_folder + + +static func copy_file(from_file :String, to_dir :String) -> GdUnitResult: + var dir := DirAccess.open(to_dir) + if dir != null: + var to_file := to_dir + "/" + from_file.get_file() + prints("Copy %s to %s" % [from_file, to_file]) + var error := dir.copy(from_file, to_file) + if error != OK: + return GdUnitResult.error("Can't copy file form '%s' to '%s'. Error: '%s'" % [from_file, to_file, error_string(error)]) + return GdUnitResult.success(to_file) + return GdUnitResult.error("Directory not found: " + to_dir) + + +static func copy_directory(from_dir :String, to_dir :String, recursive :bool = false) -> bool: + if not DirAccess.dir_exists_absolute(from_dir): + push_error("Source directory not found '%s'" % from_dir) + return false + + # check if destination exists + if not DirAccess.dir_exists_absolute(to_dir): + # create it + var err := DirAccess.make_dir_recursive_absolute(to_dir) + if err != OK: + push_error("Can't create directory '%s'. Error: %s" % [to_dir, error_string(err)]) + return false + var source_dir := DirAccess.open(from_dir) + var dest_dir := DirAccess.open(to_dir) + if source_dir != null: + @warning_ignore("return_value_discarded") + source_dir.list_dir_begin() + var next := "." + + while next != "": + next = source_dir.get_next() + if next == "" or next == "." or next == "..": + continue + var source := source_dir.get_current_dir() + "/" + next + var dest := dest_dir.get_current_dir() + "/" + next + if source_dir.current_is_dir(): + if recursive: + @warning_ignore("return_value_discarded") + copy_directory(source + "/", dest, recursive) + continue + var err := source_dir.copy(source, dest) + if err != OK: + push_error("Error checked copy file '%s' to '%s'" % [source, dest]) + return false + + return true + else: + push_error("Directory not found: " + from_dir) + return false + + +static func delete_directory(path :String, only_content := false) -> void: + var dir := DirAccess.open(path) + if dir != null: + dir.include_hidden = true + @warning_ignore("return_value_discarded") + dir.list_dir_begin() + var file_name := "." + while file_name != "": + file_name = dir.get_next() + if file_name.is_empty() or file_name == "." or file_name == "..": + continue + var next := path + "/" +file_name + if dir.current_is_dir(): + delete_directory(next) + else: + # delete file + var err := dir.remove(next) + if err: + push_error("Delete %s failed: %s" % [next, error_string(err)]) + if not only_content: + var err := dir.remove(path) + if err: + push_error("Delete %s failed: %s" % [path, error_string(err)]) + + +static func delete_path_index_lower_equals_than(path :String, prefix :String, index :int) -> int: + var dir := DirAccess.open(path) + if dir == null: + return 0 + var deleted := 0 + @warning_ignore("return_value_discarded") + dir.list_dir_begin() + var next := "." + while next != "": + next = dir.get_next() + if next.is_empty() or next == "." or next == "..": + continue + if next.begins_with(prefix): + var current_index := next.split("_")[1].to_int() + if current_index <= index: + deleted += 1 + delete_directory(path + "/" + next) + return deleted + + +# scans given path for sub directories by given prefix and returns the highest index numer +# e.g. +static func find_last_path_index(path :String, prefix :String) -> int: + var dir := DirAccess.open(path) + if dir == null: + return 0 + var last_iteration := 0 + @warning_ignore("return_value_discarded") + dir.list_dir_begin() + var next := "." + while next != "": + next = dir.get_next() + if next.is_empty() or next == "." or next == "..": + continue + if next.begins_with(prefix): + var iteration := next.split("_")[1].to_int() + if iteration > last_iteration: + last_iteration = iteration + return last_iteration + + +static func as_resource_path(value: String) -> String: + if value.begins_with("res://"): + return value + return "res://" + value.trim_prefix("//").trim_prefix("/").trim_suffix("/") + + +static func scan_dir(path :String) -> PackedStringArray: + var dir := DirAccess.open(path) + if dir == null or not dir.dir_exists(path): + return PackedStringArray() + var content := PackedStringArray() + dir.include_hidden = true + @warning_ignore("return_value_discarded") + dir.list_dir_begin() + var next := "." + while next != "": + next = dir.get_next() + if next.is_empty() or next == "." or next == "..": + continue + @warning_ignore("return_value_discarded") + content.append(next) + return content + + +static func resource_as_array(resource_path :String) -> PackedStringArray: + var file := FileAccess.open(resource_path, FileAccess.READ) + if file == null: + push_error("ERROR: Can't read resource '%s'. %s" % [resource_path, error_string(FileAccess.get_open_error())]) + return PackedStringArray() + var file_content := PackedStringArray() + while not file.eof_reached(): + @warning_ignore("return_value_discarded") + file_content.append(file.get_line()) + return file_content + + +static func resource_as_string(resource_path :String) -> String: + var file := FileAccess.open(resource_path, FileAccess.READ) + if file == null: + push_error("ERROR: Can't read resource '%s'. %s" % [resource_path, error_string(FileAccess.get_open_error())]) + return "" + return file.get_as_text() + + +static func make_qualified_path(path :String) -> String: + if path.begins_with("res://"): + return path + if path.begins_with("//"): + return path.replace("//", "res://") + if path.begins_with("/"): + return "res:/" + path + return path + + +static func extract_zip(zip_package :String, dest_path :String) -> GdUnitResult: + var zip: ZIPReader = ZIPReader.new() + var err := zip.open(zip_package) + if err != OK: + return GdUnitResult.error("Extracting `%s` failed! Please collect the error log and report this. Error Code: %s" % [zip_package, err]) + var zip_entries: PackedStringArray = zip.get_files() + # Get base path and step over archive folder + var archive_path := zip_entries[0] + zip_entries.remove_at(0) + + for zip_entry in zip_entries: + var new_file_path: String = dest_path + "/" + zip_entry.replace(archive_path, "") + if zip_entry.ends_with("/"): + @warning_ignore("return_value_discarded") + DirAccess.make_dir_recursive_absolute(new_file_path) + continue + var file: FileAccess = FileAccess.open(new_file_path, FileAccess.WRITE) + file.store_buffer(zip.read_file(zip_entry)) + @warning_ignore("return_value_discarded") + zip.close() + return GdUnitResult.success(dest_path) diff --git a/addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid b/addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid new file mode 100644 index 0000000..4cf53af --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid @@ -0,0 +1 @@ +uid://bsuhbvkjleu6v diff --git a/addons/gdUnit4/src/core/GdUnitProperty.gd b/addons/gdUnit4/src/core/GdUnitProperty.gd new file mode 100644 index 0000000..3d050b1 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitProperty.gd @@ -0,0 +1,81 @@ +class_name GdUnitProperty +extends RefCounted + + +var _name :String +var _help :String +var _type :int +var _value :Variant +var _value_set :PackedStringArray +var _default :Variant + + +func _init(p_name :String, p_type :int, p_value :Variant, p_default_value :Variant, p_help :="", p_value_set := PackedStringArray()) -> void: + _name = p_name + _type = p_type + _value = p_value + _value_set = p_value_set + _default = p_default_value + _help = p_help + + +func name() -> String: + return _name + + +func type() -> int: + return _type + + +func value() -> Variant: + return _value + + +func int_value() -> int: + return _value + +func value_as_string() -> String: + return _value + + +func value_set() -> PackedStringArray: + return _value_set + + +func is_selectable_value() -> bool: + return not _value_set.is_empty() + + +func set_value(p_value: Variant) -> void: + match _type: + TYPE_STRING: + _value = str(p_value) + TYPE_BOOL: + _value = type_convert(p_value, TYPE_BOOL) + TYPE_INT: + _value = type_convert(p_value, TYPE_INT) + TYPE_FLOAT: + _value = type_convert(p_value, TYPE_FLOAT) + TYPE_DICTIONARY: + _value = type_convert(p_value, TYPE_DICTIONARY) + _: + _value = p_value + + +func default() -> Variant: + return _default + + +func category() -> String: + var elements := _name.split("/") + if elements.size() > 3: + return elements[2] + return "" + + +func help() -> String: + return _help + + +func _to_string() -> String: + return "%-64s %-10s %-10s (%s) help:%s set:%s" % [name(), type(), value(), default(), help(), _value_set] diff --git a/addons/gdUnit4/src/core/GdUnitProperty.gd.uid b/addons/gdUnit4/src/core/GdUnitProperty.gd.uid new file mode 100644 index 0000000..8c78724 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitProperty.gd.uid @@ -0,0 +1 @@ +uid://ia2jk52kd8fi diff --git a/addons/gdUnit4/src/core/GdUnitResult.gd b/addons/gdUnit4/src/core/GdUnitResult.gd new file mode 100644 index 0000000..42392a5 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitResult.gd @@ -0,0 +1,109 @@ +class_name GdUnitResult +extends RefCounted + +enum { + SUCCESS, + WARN, + ERROR, + EMPTY +} + +var _state: int +var _warn_message := "" +var _error_message := "" +var _value :Variant = null + + +static func empty() -> GdUnitResult: + var result := GdUnitResult.new() + result._state = EMPTY + return result + + +static func success(p_value: Variant = "") -> GdUnitResult: + assert(p_value != null, "The value must not be NULL") + var result := GdUnitResult.new() + result._value = p_value + result._state = SUCCESS + return result + + +static func warn(p_warn_message: String, p_value: Variant = null) -> GdUnitResult: + assert(not p_warn_message.is_empty()) #,"The message must not be empty") + var result := GdUnitResult.new() + result._value = p_value + result._warn_message = p_warn_message + result._state = WARN + return result + + +static func error(p_error_message: String) -> GdUnitResult: + assert(not p_error_message.is_empty(), "The message must not be empty") + var result := GdUnitResult.new() + result._value = null + result._error_message = p_error_message + result._state = ERROR + return result + + +func is_success() -> bool: + return _state == SUCCESS + + +func is_warn() -> bool: + return _state == WARN + + +func is_error() -> bool: + return _state == ERROR + + +func is_empty() -> bool: + return _state == EMPTY + + +func value() -> Variant: + return _value + + +func value_as_string() -> String: + return _value + + +func or_else(p_value: Variant) -> Variant: + if not is_success(): + return p_value + return value() + + +func error_message() -> String: + return _error_message + + +func warn_message() -> String: + return _warn_message + + +func _to_string() -> String: + return str(GdUnitResult.serialize(self)) + + +static func serialize(result: GdUnitResult) -> Dictionary: + if result == null: + push_error("Can't serialize a Null object from type GdUnitResult") + return { + "state" : result._state, + "value" : var_to_str(result._value), + "warn_msg" : result._warn_message, + "err_msg" : result._error_message + } + + +static func deserialize(config: Dictionary) -> GdUnitResult: + var result := GdUnitResult.new() + var cfg_value: String = config.get("value", "") + result._value = str_to_var(cfg_value) + result._warn_message = config.get("warn_msg", null) + result._error_message = config.get("err_msg", null) + result._state = config.get("state") + return result diff --git a/addons/gdUnit4/src/core/GdUnitResult.gd.uid b/addons/gdUnit4/src/core/GdUnitResult.gd.uid new file mode 100644 index 0000000..a10501f --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitResult.gd.uid @@ -0,0 +1 @@ +uid://beoe0j1nnn7a diff --git a/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd new file mode 100644 index 0000000..0d1a42d --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd @@ -0,0 +1,139 @@ +class_name GdUnitRunnerConfig +extends Resource + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +const CONFIG_VERSION = "5.0" +const VERSION = "version" +const TESTS = "tests" +const SERVER_PORT = "server_port" +const EXIT_FAIL_FAST = "exit_on_first_fail" + +const CONFIG_FILE = "res://addons/gdUnit4/GdUnitRunner.cfg" + +var _config := { + VERSION : CONFIG_VERSION, + # a set of directories or testsuite paths as key and a optional set of testcases as values + + TESTS : Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase), + + # the port of running test server for this session + SERVER_PORT : -1, + + # Exit on first failure + EXIT_FAIL_FAST : false + } + + +func version() -> String: + return _config[VERSION] + + +func clear() -> GdUnitRunnerConfig: + _config[TESTS] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase) + return self + + +func set_server_port(port: int) -> GdUnitRunnerConfig: + _config[SERVER_PORT] = port + return self + + +func server_port() -> int: + return _config.get(SERVER_PORT, -1) + + +func do_fail_fast(fail_fast: bool) -> GdUnitRunnerConfig: + _config[EXIT_FAIL_FAST] = fail_fast + return self + + +func is_fail_fast() -> bool: + return _config.get(EXIT_FAIL_FAST, false) + + +func add_test_cases(tests: Array[GdUnitTestCase]) -> GdUnitRunnerConfig: + test_cases().append_array(tests) + return self + + +func test_cases() -> Array[GdUnitTestCase]: + return _config.get(TESTS, []) + + +func save_config(path: String = CONFIG_FILE) -> GdUnitResult: + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + var error := FileAccess.get_open_error() + return GdUnitResult.error("Can't write test runner configuration '%s'! %s" % [path, error_string(error)]) + + var to_save := { + VERSION : CONFIG_VERSION, + EXIT_FAIL_FAST : is_fail_fast(), + SERVER_PORT : server_port(), + TESTS : Array() + } + + var tests: Array = to_save.get(TESTS) + for test in test_cases(): + tests.append(inst_to_dict(test)) + file.store_string(JSON.stringify(to_save, "\t")) + return GdUnitResult.success(path) + + +func load_config(path: String = CONFIG_FILE) -> GdUnitResult: + if not FileAccess.file_exists(path): + return GdUnitResult.warn("Can't find test runner configuration '%s'! Please select a test to run." % path) + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + var error := FileAccess.get_open_error() + return GdUnitResult.error("Can't load test runner configuration '%s'! ERROR: %s." % [path, error_string(error)]) + var content := file.get_as_text() + if not content.is_empty() and content[0] == '{': + # Parse as json + var test_json_conv := JSON.new() + var error := test_json_conv.parse(content) + if error != OK: + return GdUnitResult.error("The runner configuration '%s' is invalid! The format is changed please delete it manually and start a new test run." % path) + var config: Dictionary = test_json_conv.get_data() + if not config.has(VERSION): + return GdUnitResult.error("The runner configuration '%s' is invalid! The format is changed please delete it manually and start a new test run." % path) + + var default: Array[Dictionary] = Array([], TYPE_DICTIONARY, "", null) + var tests_as_json: Array = config.get(TESTS, default) + _config = config + _config[TESTS] = convert_test_json_to_test_cases(tests_as_json) + + + fix_value_types() + return GdUnitResult.success(path) + + +func convert_test_json_to_test_cases(jsons: Array) -> Array[GdUnitTestCase]: + if jsons.is_empty(): + return [] + var tests := jsons.map(func(d: Dictionary) -> GdUnitTestCase: + var test: GdUnitTestCase = dict_to_inst(d) + # we need o covert manually to the corect type becaus JSON do not handle typed values + test.guid = GdUnitGUID.new(str(d["guid"])) + test.attribute_index = test.attribute_index as int + test.line_number = test.line_number as int + return test + ) + return Array(tests, TYPE_OBJECT, "RefCounted", GdUnitTestCase) + + +func fix_value_types() -> void: + # fix float value to int json stores all numbers as float + var server_port_: int = _config.get(SERVER_PORT, -1) + _config[SERVER_PORT] = server_port_ + + +func convert_Array_to_PackedStringArray(data: Dictionary) -> void: + for key in data.keys() as Array[String]: + var values :Array = data[key] + data[key] = PackedStringArray(values) + + +func _to_string() -> String: + return str(_config) diff --git a/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid new file mode 100644 index 0000000..9199e1f --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid @@ -0,0 +1 @@ +uid://bcfkr8nvfm1a2 diff --git a/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd new file mode 100644 index 0000000..8c298eb --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd @@ -0,0 +1,644 @@ +# This class provides a runner for scense to simulate interactions like keyboard or mouse +class_name GdUnitSceneRunnerImpl +extends GdUnitSceneRunner + + +var GdUnitFuncAssertImpl: GDScript = ResourceLoader.load("res://addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd", "GDScript", ResourceLoader.CACHE_MODE_REUSE) + + +# mapping of mouse buttons and his masks +const MAP_MOUSE_BUTTON_MASKS := { + MOUSE_BUTTON_LEFT : MOUSE_BUTTON_MASK_LEFT, + MOUSE_BUTTON_RIGHT : MOUSE_BUTTON_MASK_RIGHT, + MOUSE_BUTTON_MIDDLE : MOUSE_BUTTON_MASK_MIDDLE, + # https://github.com/godotengine/godot/issues/73632 + MOUSE_BUTTON_WHEEL_UP : 1 << (MOUSE_BUTTON_WHEEL_UP - 1), + MOUSE_BUTTON_WHEEL_DOWN : 1 << (MOUSE_BUTTON_WHEEL_DOWN - 1), + MOUSE_BUTTON_XBUTTON1 : MOUSE_BUTTON_MASK_MB_XBUTTON1, + MOUSE_BUTTON_XBUTTON2 : MOUSE_BUTTON_MASK_MB_XBUTTON2, +} + +var _is_disposed := false +var _current_scene: Node = null +var _awaiter: GdUnitAwaiter = GdUnitAwaiter.new() +var _verbose: bool +var _simulate_start_time: LocalTime +var _last_input_event: InputEvent = null +var _mouse_button_on_press := [] +var _key_on_press := [] +var _action_on_press := [] +var _curent_mouse_position: Vector2 +# holds the touch position for each touch index +# { index: int = position: Vector2} +var _current_touch_position: Dictionary = {} +# holds the curretn touch drag position +var _current_touch_drag_position: Vector2 = Vector2.ZERO + +# time factor settings +var _time_factor := 1.0 +var _scene_auto_free := false + + +func _init(p_scene: Variant, p_verbose: bool, p_hide_push_errors := false) -> void: + _verbose = p_verbose + @warning_ignore("return_value_discarded") + set_time_factor(1) + # handle scene loading by resource path + if typeof(p_scene) == TYPE_STRING: + @warning_ignore("unsafe_cast") + if !ResourceLoader.exists(p_scene as String): + if not p_hide_push_errors: + push_error("GdUnitSceneRunner: Can't load scene by given resource path: '%s'. The resource does not exists." % p_scene) + return + if !str(p_scene).ends_with(".tscn") and !str(p_scene).ends_with(".scn") and !str(p_scene).begins_with("uid://"): + if not p_hide_push_errors: + push_error("GdUnitSceneRunner: The given resource: '%s'. is not a scene." % p_scene) + return + @warning_ignore("unsafe_cast") + _current_scene = (load(p_scene as String) as PackedScene).instantiate() + _scene_auto_free = true + else: + # verify we have a node instance + if not p_scene is Node: + if not p_hide_push_errors: + push_error("GdUnitSceneRunner: The given instance '%s' is not a Node." % p_scene) + return + _current_scene = p_scene + if _current_scene == null: + if not p_hide_push_errors: + push_error("GdUnitSceneRunner: Scene must be not null!") + return + + _scene_tree().root.add_child(_current_scene) + Engine.set_meta("GdUnitSceneRunner", self) + # do finally reset all open input events when the scene is removed + @warning_ignore("return_value_discarded") + _scene_tree().root.child_exiting_tree.connect(func f(child: Node) -> void: + if child == _current_scene: + _reset_input_to_default() + ) + _simulate_start_time = LocalTime.now() + # we need to set inital a valid window otherwise the warp_mouse() is not handled + move_window_to_foreground() + + # set inital mouse pos to 0,0 + var max_iteration_to_wait := 0 + while get_global_mouse_position() != Vector2.ZERO and max_iteration_to_wait < 100: + Input.warp_mouse(Vector2.ZERO) + max_iteration_to_wait += 1 + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE and is_instance_valid(self): + # reset time factor to normal + __deactivate_time_factor() + if is_instance_valid(_current_scene): + move_window_to_background() + _scene_tree().root.remove_child(_current_scene) + # do only free scenes instanciated by this runner + if _scene_auto_free: + _current_scene.free() + _is_disposed = true + _current_scene = null + Engine.remove_meta("GdUnitSceneRunner") + + +func _scene_tree() -> SceneTree: + return Engine.get_main_loop() as SceneTree + + +func await_input_processed() -> void: + if scene() != null and scene().process_mode != Node.PROCESS_MODE_DISABLED: + Input.flush_buffered_events() + await (Engine.get_main_loop() as SceneTree).process_frame + await (Engine.get_main_loop() as SceneTree).physics_frame + + +@warning_ignore("return_value_discarded") +func simulate_action_pressed(action: String, event_index := -1) -> GdUnitSceneRunner: + simulate_action_press(action, event_index) + simulate_action_release(action, event_index) + return self + + +func simulate_action_press(action: String, event_index := -1) -> GdUnitSceneRunner: + __print_current_focus() + var event := InputEventAction.new() + event.pressed = true + event.action = action + event.event_index = event_index + _action_on_press.append(action) + return _handle_input_event(event) + + +func simulate_action_release(action: String, event_index := -1) -> GdUnitSceneRunner: + __print_current_focus() + var event := InputEventAction.new() + event.pressed = false + event.action = action + event.event_index = event_index + _action_on_press.erase(action) + return _handle_input_event(event) + + +@warning_ignore("return_value_discarded") +func simulate_key_pressed(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner: + _push_warning_deprecated_arguments(shift_pressed, ctrl_pressed) + simulate_key_press(key_code, shift_pressed, ctrl_pressed) + await _scene_tree().process_frame + simulate_key_release(key_code, shift_pressed, ctrl_pressed) + return self + + +func simulate_key_press(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner: + _push_warning_deprecated_arguments(shift_pressed, ctrl_pressed) + __print_current_focus() + var event := InputEventKey.new() + event.pressed = true + event.keycode = key_code as Key + event.physical_keycode = key_code as Key + event.unicode = key_code + event.set_alt_pressed(key_code == KEY_ALT) + event.set_shift_pressed(shift_pressed) + event.set_ctrl_pressed(ctrl_pressed) + event.get_modifiers_mask() + _apply_input_modifiers(event) + _key_on_press.append(key_code) + return _handle_input_event(event) + + +func simulate_key_release(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner: + _push_warning_deprecated_arguments(shift_pressed, ctrl_pressed) + __print_current_focus() + var event := InputEventKey.new() + event.pressed = false + event.keycode = key_code as Key + event.physical_keycode = key_code as Key + event.unicode = key_code + event.set_alt_pressed(key_code == KEY_ALT) + event.set_shift_pressed(shift_pressed) + event.set_ctrl_pressed(ctrl_pressed) + _apply_input_modifiers(event) + _key_on_press.erase(key_code) + return _handle_input_event(event) + + +func set_mouse_position(pos: Vector2) -> GdUnitSceneRunner: + var event := InputEventMouseMotion.new() + event.position = pos + event.global_position = get_global_mouse_position() + _apply_input_modifiers(event) + return _handle_input_event(event) + + +func get_mouse_position() -> Vector2: + if _last_input_event is InputEventMouse: + return (_last_input_event as InputEventMouse).position + var current_scene := scene() + if current_scene != null: + return current_scene.get_viewport().get_mouse_position() + return Vector2.ZERO + + +func get_global_mouse_position() -> Vector2: + return (Engine.get_main_loop() as SceneTree).root.get_mouse_position() + + +func simulate_mouse_move(position: Vector2) -> GdUnitSceneRunner: + var event := InputEventMouseMotion.new() + event.position = position + event.relative = position - get_mouse_position() + event.global_position = get_global_mouse_position() + _apply_input_mouse_mask(event) + _apply_input_modifiers(event) + return _handle_input_event(event) + + +@warning_ignore("return_value_discarded") +func simulate_mouse_move_relative(relative: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner: + var tween := _scene_tree().create_tween() + _curent_mouse_position = get_mouse_position() + var final_position := _curent_mouse_position + relative + tween.tween_property(self, "_curent_mouse_position", final_position, time).set_trans(trans_type) + tween.play() + + while not get_mouse_position().is_equal_approx(final_position): + simulate_mouse_move(_curent_mouse_position) + await _scene_tree().process_frame + return self + + +@warning_ignore("return_value_discarded") +func simulate_mouse_move_absolute(position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner: + var tween := _scene_tree().create_tween() + _curent_mouse_position = get_mouse_position() + tween.tween_property(self, "_curent_mouse_position", position, time).set_trans(trans_type) + tween.play() + + while not get_mouse_position().is_equal_approx(position): + simulate_mouse_move(_curent_mouse_position) + await _scene_tree().process_frame + return self + + +@warning_ignore("return_value_discarded") +func simulate_mouse_button_pressed(button_index: MouseButton, double_click := false) -> GdUnitSceneRunner: + simulate_mouse_button_press(button_index, double_click) + simulate_mouse_button_release(button_index) + return self + + +func simulate_mouse_button_press(button_index: MouseButton, double_click := false) -> GdUnitSceneRunner: + var event := InputEventMouseButton.new() + event.button_index = button_index + event.pressed = true + event.double_click = double_click + _apply_input_mouse_position(event) + _apply_input_mouse_mask(event) + _apply_input_modifiers(event) + _mouse_button_on_press.append(button_index) + return _handle_input_event(event) + + +func simulate_mouse_button_release(button_index: MouseButton) -> GdUnitSceneRunner: + var event := InputEventMouseButton.new() + event.button_index = button_index + event.pressed = false + _apply_input_mouse_position(event) + _apply_input_mouse_mask(event) + _apply_input_modifiers(event) + _mouse_button_on_press.erase(button_index) + return _handle_input_event(event) + + +@warning_ignore("return_value_discarded") +func simulate_screen_touch_pressed(index: int, position: Vector2, double_tap := false) -> GdUnitSceneRunner: + simulate_screen_touch_press(index, position, double_tap) + simulate_screen_touch_release(index) + return self + + +@warning_ignore("return_value_discarded") +func simulate_screen_touch_press(index: int, position: Vector2, double_tap := false) -> GdUnitSceneRunner: + if is_emulate_mouse_from_touch(): + # we need to simulate in addition to the touch the mouse events + set_mouse_position(position) + simulate_mouse_button_press(MOUSE_BUTTON_LEFT) + # push touch press event at position + var event := InputEventScreenTouch.new() + event.window_id = scene().get_window().get_window_id() + event.index = index + event.position = position + event.double_tap = double_tap + event.pressed = true + _current_scene.get_viewport().push_input(event) + # save current drag position by index + _current_touch_position[index] = position + return self + + +@warning_ignore("return_value_discarded") +func simulate_screen_touch_release(index: int, double_tap := false) -> GdUnitSceneRunner: + if is_emulate_mouse_from_touch(): + # we need to simulate in addition to the touch the mouse events + simulate_mouse_button_release(MOUSE_BUTTON_LEFT) + # push touch release event at position + var event := InputEventScreenTouch.new() + event.window_id = scene().get_window().get_window_id() + event.index = index + event.position = get_screen_touch_drag_position(index) + event.pressed = false + event.double_tap = (_last_input_event as InputEventScreenTouch).double_tap if _last_input_event is InputEventScreenTouch else double_tap + _current_scene.get_viewport().push_input(event) + return self + + +func simulate_screen_touch_drag_relative(index: int, relative: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner: + var current_position: Vector2 = _current_touch_position[index] + return await _do_touch_drag_at(index, current_position + relative, time, trans_type) + + +func simulate_screen_touch_drag_absolute(index: int, position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner: + return await _do_touch_drag_at(index, position, time, trans_type) + + +@warning_ignore("return_value_discarded") +func simulate_screen_touch_drag_drop(index: int, position: Vector2, drop_position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner: + simulate_screen_touch_press(index, position) + return await _do_touch_drag_at(index, drop_position, time, trans_type) + + +@warning_ignore("return_value_discarded") +func simulate_screen_touch_drag(index: int, position: Vector2) -> GdUnitSceneRunner: + if is_emulate_mouse_from_touch(): + simulate_mouse_move(position) + var event := InputEventScreenDrag.new() + event.window_id = scene().get_window().get_window_id() + event.index = index + event.position = position + event.relative = _get_screen_touch_drag_position_or_default(index, position) - position + event.velocity = event.relative / _scene_tree().root.get_process_delta_time() + event.pressure = 1.0 + _current_touch_position[index] = position + _current_scene.get_viewport().push_input(event) + return self + + +func get_screen_touch_drag_position(index: int) -> Vector2: + if _current_touch_position.has(index): + return _current_touch_position[index] + push_error("No touch drag position for index '%d' is set!" % index) + return Vector2.ZERO + + +func is_emulate_mouse_from_touch() -> bool: + return ProjectSettings.get_setting("input_devices/pointing/emulate_mouse_from_touch", true) + + +func _get_screen_touch_drag_position_or_default(index: int, default_position: Vector2) -> Vector2: + if _current_touch_position.has(index): + return _current_touch_position[index] + return default_position + + +@warning_ignore("return_value_discarded") +func _do_touch_drag_at(index: int, drag_position: Vector2, time: float, trans_type: Tween.TransitionType) -> GdUnitSceneRunner: + # start draging + var event := InputEventScreenDrag.new() + event.window_id = scene().get_window().get_window_id() + event.index = index + event.position = get_screen_touch_drag_position(index) + event.pressure = 1.0 + _current_touch_drag_position = event.position + + var tween := _scene_tree().create_tween() + tween.tween_property(self, "_current_touch_drag_position", drag_position, time).set_trans(trans_type) + tween.play() + + while not _current_touch_drag_position.is_equal_approx(drag_position): + if is_emulate_mouse_from_touch(): + # we need to simulate in addition to the drag the mouse move events + simulate_mouse_move(event.position) + # send touche drag event to new position + event.relative = _current_touch_drag_position - event.position + event.velocity = event.relative / _scene_tree().root.get_process_delta_time() + event.position = _current_touch_drag_position + _current_scene.get_viewport().push_input(event) + await _scene_tree().process_frame + + # finaly drop it + if is_emulate_mouse_from_touch(): + simulate_mouse_move(drag_position) + simulate_mouse_button_release(MOUSE_BUTTON_LEFT) + var touch_drop_event := InputEventScreenTouch.new() + touch_drop_event.window_id = event.window_id + touch_drop_event.index = event.index + touch_drop_event.position = drag_position + touch_drop_event.pressed = false + _current_scene.get_viewport().push_input(touch_drop_event) + await _scene_tree().process_frame + return self + + +func set_time_factor(time_factor: float = 1.0) -> GdUnitSceneRunner: + _time_factor = min(9.0, time_factor) + __activate_time_factor() + __print("set time factor: %f" % _time_factor) + __print("set physics physics_ticks_per_second: %d" % (get_project_physics_ticks_per_second() * _time_factor)) + return self + + +func simulate_frames(frames: int, delta_milli: int = -1) -> GdUnitSceneRunner: + var time_shift_frames :int = max(1, frames / _time_factor) + for frame in time_shift_frames: + if delta_milli == -1: + await _scene_tree().process_frame + else: + await _scene_tree().create_timer(delta_milli * 0.001).timeout + return self + + +func simulate_until_signal(signal_name: String, ...args: Array) -> GdUnitSceneRunner: + await _awaiter.await_signal_idle_frames(scene(), signal_name, args, 10000) + return self + + +func simulate_until_object_signal(source: Object, signal_name: String, ...args: Array) -> GdUnitSceneRunner: + await _awaiter.await_signal_idle_frames(source, signal_name, args, 10000) + return self + + +func await_func(func_name: String, ...args: Array) -> GdUnitFuncAssert: + return GdUnitFuncAssertImpl.new(scene(), func_name, args) + + +func await_func_on(instance: Object, func_name: String, ...args: Array) -> GdUnitFuncAssert: + return GdUnitFuncAssertImpl.new(instance, func_name, args) + + +func await_signal(signal_name: String, args := [], timeout := 2000 ) -> void: + await _awaiter.await_signal_on(scene(), signal_name, args, timeout) + + +func await_signal_on(source: Object, signal_name: String, args := [], timeout := 2000 ) -> void: + await _awaiter.await_signal_on(source, signal_name, args, timeout) + + +func move_window_to_foreground() -> GdUnitSceneRunner: + if not Engine.is_embedded_in_editor(): + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + DisplayServer.window_move_to_foreground() + return self + + +func move_window_to_background() -> GdUnitSceneRunner: + if not Engine.is_embedded_in_editor(): + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED) + return self + + +func _property_exists(name: String) -> bool: + return scene().get_property_list().any(func(properties :Dictionary) -> bool: return properties["name"] == name) + + +func get_property(name: String) -> Variant: + if not _property_exists(name): + return "The property '%s' not exist checked loaded scene." % name + return scene().get(name) + + +func set_property(name: String, value: Variant) -> bool: + if not _property_exists(name): + push_error("The property named '%s' cannot be set, it does not exist!" % name) + return false; + scene().set(name, value) + return true + + +func invoke(name: String, ...args: Array) -> Variant: + if scene().has_method(name): + return await scene().callv(name, args) + return "The method '%s' not exist checked loaded scene." % name + + +func find_child(name: String, recursive: bool = true, owned: bool = false) -> Node: + return scene().find_child(name, recursive, owned) + + +func _scene_name() -> String: + if scene() == null: + return "unknown" + var scene_script :GDScript = scene().get_script() + var scene_name :String = scene().get_name() + if not scene_script: + return scene_name + if not scene_name.begins_with("@"): + return scene_name + return scene_script.resource_name.get_basename() + + +func __activate_time_factor() -> void: + Engine.set_time_scale(_time_factor) + Engine.set_physics_ticks_per_second((get_project_physics_ticks_per_second() * _time_factor) as int) + + +func __deactivate_time_factor() -> void: + Engine.set_time_scale(1) + Engine.set_physics_ticks_per_second(get_project_physics_ticks_per_second()) + + +func get_project_physics_ticks_per_second() -> int: + return ProjectSettings.get_setting("physics/common/physics_ticks_per_second") + + +# copy over current active modifiers +func _apply_input_modifiers(event: InputEvent) -> void: + if _last_input_event is InputEventWithModifiers and event is InputEventWithModifiers: + var last_input_event := _last_input_event as InputEventWithModifiers + var _event := event as InputEventWithModifiers + _event.meta_pressed = _event.meta_pressed or last_input_event.meta_pressed + _event.alt_pressed = _event.alt_pressed or last_input_event.alt_pressed + _event.shift_pressed = _event.shift_pressed or last_input_event.shift_pressed + _event.ctrl_pressed = _event.ctrl_pressed or last_input_event.ctrl_pressed + # this line results into reset the control_pressed state!!! + #event.command_or_control_autoremap = event.command_or_control_autoremap or _last_input_event.command_or_control_autoremap + if _last_input_event is InputEventKey and event is InputEventWithModifiers: + var last_input_event := _last_input_event as InputEventKey + var _event := event as InputEventWithModifiers + _event.shift_pressed = _event.shift_pressed or last_input_event.keycode == KEY_SHIFT + _event.alt_pressed = _event.alt_pressed or last_input_event.keycode == KEY_ALT + _event.ctrl_pressed = _event.ctrl_pressed or last_input_event.keycode == KEY_CTRL + _event.meta_pressed = _event.meta_pressed or last_input_event.keycode == KEY_META + + +# copy over current active mouse mask and combine with curren mask +func _apply_input_mouse_mask(event: InputEvent) -> void: + # first apply last mask + if _last_input_event is InputEventMouse and event is InputEventMouse: + (event as InputEventMouse).button_mask |= (_last_input_event as InputEventMouse).button_mask + if event is InputEventMouseButton: + var _event := event as InputEventMouseButton + var button_mask :int = MAP_MOUSE_BUTTON_MASKS.get(_event.get_button_index(), 0) + if _event.is_pressed(): + _event.button_mask |= button_mask + else: + _event.button_mask ^= button_mask + + +# copy over last mouse position if need +func _apply_input_mouse_position(event: InputEvent) -> void: + if _last_input_event is InputEventMouse and event is InputEventMouseButton: + (event as InputEventMouseButton).position = (_last_input_event as InputEventMouse).position + + +## handle input action via Input modifieres +func _handle_actions(event: InputEventAction) -> bool: + if not InputMap.event_is_action(event, event.action, true): + return false + __print(" process action %s (%s) <- %s" % [scene(), _scene_name(), event.as_text()]) + if event.is_pressed(): + Input.action_press(event.action, event.get_strength()) + else: + Input.action_release(event.action) + return true + + +# for handling read https://docs.godotengine.org/en/stable/tutorials/inputs/inputevent.html?highlight=inputevent#how-does-it-work +@warning_ignore("return_value_discarded") +func _handle_input_event(event: InputEvent) -> GdUnitSceneRunner: + if event is InputEventMouse: + Input.warp_mouse((event as InputEventMouse).position as Vector2) + Input.parse_input_event(event) + + if event is InputEventAction: + _handle_actions(event as InputEventAction) + + var current_scene := scene() + if is_instance_valid(current_scene): + # do not flush events if node processing disabled otherwise we run into errors at tree removed + if _current_scene.process_mode != Node.PROCESS_MODE_DISABLED: + Input.flush_buffered_events() + __print(" process event %s (%s) <- %s" % [current_scene, _scene_name(), event.as_text()]) + if(current_scene.has_method("_gui_input")): + (current_scene as Control)._gui_input(event) + if(current_scene.has_method("_unhandled_input")): + current_scene._unhandled_input(event) + current_scene.get_viewport().set_input_as_handled() + + # save last input event needs to be merged with next InputEventMouseButton + _last_input_event = event + return self + + +@warning_ignore("return_value_discarded") +func _reset_input_to_default() -> void: + # reset all mouse button to inital state if need + for m_button :int in _mouse_button_on_press.duplicate(): + if Input.is_mouse_button_pressed(m_button): + simulate_mouse_button_release(m_button) + _mouse_button_on_press.clear() + + for key_scancode :int in _key_on_press.duplicate(): + if Input.is_key_pressed(key_scancode): + simulate_key_release(key_scancode) + _key_on_press.clear() + + for action :String in _action_on_press.duplicate(): + if Input.is_action_pressed(action): + simulate_action_release(action) + _action_on_press.clear() + + if is_instance_valid(_current_scene) and _current_scene.process_mode != Node.PROCESS_MODE_DISABLED: + Input.flush_buffered_events() + _last_input_event = null + + +func __print(message: String) -> void: + if _verbose: + prints(message) + + +func __print_current_focus() -> void: + if not _verbose: + return + var focused_node := scene().get_viewport().gui_get_focus_owner() + if focused_node: + prints(" focus checked %s" % focused_node) + else: + prints(" no focus set") + + +func scene() -> Node: + if is_instance_valid(_current_scene): + return _current_scene + if not _is_disposed: + push_error("The current scene instance is not valid anymore! check your test is valid. e.g. check for missing awaits.") + return null + + +func _push_warning_deprecated_arguments(shift_pressed: bool, ctrl_pressed: bool) -> void: + if shift_pressed: + push_warning("Deprecated! Don't use 'shift_pressed' it will be removed in v7.0, checkout the documentaion how to use key combinations.") + if ctrl_pressed: + push_warning("Deprecated! Don't use 'ctrl_pressed' it will be removed in v7.0, checkout the documentaion how to use key combinations.") diff --git a/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid new file mode 100644 index 0000000..e65d398 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid @@ -0,0 +1 @@ +uid://dwcu3c8ubvybg diff --git a/addons/gdUnit4/src/core/GdUnitSettings.gd b/addons/gdUnit4/src/core/GdUnitSettings.gd new file mode 100644 index 0000000..dc556f0 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSettings.gd @@ -0,0 +1,469 @@ +@tool +class_name GdUnitSettings +extends RefCounted + + +const MAIN_CATEGORY = "gdunit4" +# Common Settings +const COMMON_SETTINGS = MAIN_CATEGORY + "/settings" + +const GROUP_COMMON = COMMON_SETTINGS + "/common" +const UPDATE_NOTIFICATION_ENABLED = GROUP_COMMON + "/update_notification_enabled" +const SERVER_TIMEOUT = GROUP_COMMON + "/server_connection_timeout_minutes" + +const GROUP_HOOKS = MAIN_CATEGORY + "/hooks" +const SESSION_HOOKS = GROUP_HOOKS + "/session_hooks" + +const GROUP_TEST = COMMON_SETTINGS + "/test" +const TEST_TIMEOUT = GROUP_TEST + "/test_timeout_seconds" +const TEST_LOOKUP_FOLDER = GROUP_TEST + "/test_lookup_folder" +const TEST_SUITE_NAMING_CONVENTION = GROUP_TEST + "/test_suite_naming_convention" +const TEST_DISCOVER_ENABLED = GROUP_TEST + "/test_discovery" +const TEST_FLAKY_CHECK = GROUP_TEST + "/flaky_check_enable" +const TEST_FLAKY_MAX_RETRIES = GROUP_TEST + "/flaky_max_retries" +const TEST_RERUN_UNTIL_FAILURE_RETRIES = GROUP_TEST + "/rerun_until_failure_retries" +const TEST_PROJECT_SETTINGS_AUTO_SAVE = GROUP_TEST + "/project_settings_auto_save" + + +# Report Setiings +const REPORT_SETTINGS = MAIN_CATEGORY + "/report" +const GROUP_GODOT = REPORT_SETTINGS + "/godot" +const REPORT_PUSH_ERRORS = GROUP_GODOT + "/push_error" +const REPORT_SCRIPT_ERRORS = GROUP_GODOT + "/script_error" +const REPORT_ORPHANS = REPORT_SETTINGS + "/verbose_orphans" +const GROUP_ASSERT = REPORT_SETTINGS + "/assert" +const REPORT_ASSERT_WARNINGS = GROUP_ASSERT + "/verbose_warnings" +const REPORT_ASSERT_ERRORS = GROUP_ASSERT + "/verbose_errors" +const REPORT_ASSERT_STRICT_NUMBER_TYPE_COMPARE = GROUP_ASSERT + "/strict_number_type_compare" + +# Godot debug stdout/logging settings +const CATEGORY_LOGGING := "debug/file_logging/" +const STDOUT_ENABLE_TO_FILE = CATEGORY_LOGGING + "enable_file_logging" +const STDOUT_WITE_TO_FILE = CATEGORY_LOGGING + "log_path" + +# Godot GDScript warning settings +const CATEGORY_GDSCRIPT_WARNINGS := "debug/gdscript/warnings/" +const GDSCRIPT_WARNINGS_INFERRED_DECLARATION := CATEGORY_GDSCRIPT_WARNINGS + "inferred_declaration" +const GDSCRIPT_WARNINGS_EXCLUDE_ADDONS := CATEGORY_GDSCRIPT_WARNINGS + "exclude_addons" +const GDSCRIPT_WARNINGS_DIRECTORY_RULES := CATEGORY_GDSCRIPT_WARNINGS + "directory_rules" + +enum GdScriptWarningMode { + IGNORE = 0, + WARN = 1, + ERROR = 2, +} + +enum GdScriptWarningDirectoryMode { + EXCLUDE = 0, + INCLUDE = 1, +} + + +# GdUnit Templates +const TEMPLATES = MAIN_CATEGORY + "/templates" +const TEMPLATES_TS = TEMPLATES + "/testsuite" +const TEMPLATE_TS_GD = TEMPLATES_TS + "/GDScript" +const TEMPLATE_TS_CS = TEMPLATES_TS + "/CSharpScript" + + +# UI Setiings +const UI_SETTINGS = MAIN_CATEGORY + "/ui" +const GROUP_UI_INSPECTOR = UI_SETTINGS + "/inspector" +const INSPECTOR_NODE_COLLAPSE = GROUP_UI_INSPECTOR + "/node_collapse" +const INSPECTOR_TREE_VIEW_MODE = GROUP_UI_INSPECTOR + "/tree_view_mode" +const INSPECTOR_TREE_SORT_MODE = GROUP_UI_INSPECTOR + "/tree_sort_mode" + + +# Shortcut Setiings +const SHORTCUT_SETTINGS = MAIN_CATEGORY + "/Shortcuts" +const GROUP_SHORTCUT_INSPECTOR = SHORTCUT_SETTINGS + "/inspector" +const SHORTCUT_INSPECTOR_RERUN_TEST = GROUP_SHORTCUT_INSPECTOR + "/rerun_test" +const SHORTCUT_INSPECTOR_RERUN_TEST_DEBUG = GROUP_SHORTCUT_INSPECTOR + "/rerun_test_debug" +const SHORTCUT_INSPECTOR_RUN_TEST_OVERALL = GROUP_SHORTCUT_INSPECTOR + "/run_test_overall" +const SHORTCUT_INSPECTOR_RUN_TEST_STOP = GROUP_SHORTCUT_INSPECTOR + "/run_test_stop" +const SHORTCUT_INSPECTOR_RERUN_TEST_UNTIL_FAILURE = GROUP_SHORTCUT_INSPECTOR + "/rerun_test_until_failure" + +const GROUP_SHORTCUT_EDITOR = SHORTCUT_SETTINGS + "/editor" +const SHORTCUT_EDITOR_RUN_TEST = GROUP_SHORTCUT_EDITOR + "/run_test" +const SHORTCUT_EDITOR_RUN_TEST_DEBUG = GROUP_SHORTCUT_EDITOR + "/run_test_debug" +const SHORTCUT_EDITOR_CREATE_TEST = GROUP_SHORTCUT_EDITOR + "/create_test" + +const GROUP_SHORTCUT_FILESYSTEM = SHORTCUT_SETTINGS + "/filesystem" +const SHORTCUT_FILESYSTEM_RUN_TEST = GROUP_SHORTCUT_FILESYSTEM + "/run_test" +const SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG = GROUP_SHORTCUT_FILESYSTEM + "/run_test_debug" + + +# Toolbar Setiings +const GROUP_UI_TOOLBAR = UI_SETTINGS + "/toolbar" +const INSPECTOR_TOOLBAR_BUTTON_RUN_OVERALL = GROUP_UI_TOOLBAR + "/run_overall" + +# Feature flags +const GROUP_FEATURE = MAIN_CATEGORY + "/feature" + + +# defaults +# server connection timeout in minutes +const DEFAULT_SERVER_TIMEOUT :int = 30 +# test case runtime timeout in seconds +const DEFAULT_TEST_TIMEOUT :int = 60*5 +# the folder to create new test-suites +const DEFAULT_TEST_LOOKUP_FOLDER :String = "test" + +# help texts +const HELP_TEST_LOOKUP_FOLDER := "Subfolder where test suites are located (or empty to use source folder directly)" + +enum NAMING_CONVENTIONS { + AUTO_DETECT, + SNAKE_CASE, + PASCAL_CASE, +} + +static var _property_help :Dictionary[String, String] = {} + + +static func setup() -> void: + create_property_if_need(UPDATE_NOTIFICATION_ENABLED, true, "Show notification if new gdUnit4 version is found") + # test settings + create_property_if_need(SERVER_TIMEOUT, DEFAULT_SERVER_TIMEOUT, "Server connection timeout in minutes") + create_property_if_need(TEST_TIMEOUT, DEFAULT_TEST_TIMEOUT, "Test case runtime timeout in seconds") + create_property_if_need(TEST_LOOKUP_FOLDER, DEFAULT_TEST_LOOKUP_FOLDER, HELP_TEST_LOOKUP_FOLDER) + create_property_if_need(TEST_SUITE_NAMING_CONVENTION, NAMING_CONVENTIONS.AUTO_DETECT, "Naming convention to use when generating testsuites", NAMING_CONVENTIONS.keys()) + create_property_if_need(TEST_DISCOVER_ENABLED, false, "Automatically detect new tests in test lookup folders at runtime") + create_property_if_need(TEST_FLAKY_CHECK, false, "Rerun tests on failure and mark them as FLAKY") + create_property_if_need(TEST_FLAKY_MAX_RETRIES, 3, "Sets the number of retries for rerunning a flaky test") + create_property_if_need(TEST_RERUN_UNTIL_FAILURE_RETRIES, 10, "The number of reruns until the test fails.") + create_property_if_need(TEST_PROJECT_SETTINGS_AUTO_SAVE, true, "Save and restore your project settings around each test execution") + # report settings + create_property_if_need(REPORT_PUSH_ERRORS, false, "Report push_error() as failure") + create_property_if_need(REPORT_SCRIPT_ERRORS, true, "Report script errors as failure") + create_property_if_need(REPORT_ORPHANS, true, "Report orphaned nodes after tests finish") + create_property_if_need(REPORT_ASSERT_ERRORS, true, "Report assertion failures as errors") + create_property_if_need(REPORT_ASSERT_WARNINGS, true, "Report assertion failures as warnings") + create_property_if_need(REPORT_ASSERT_STRICT_NUMBER_TYPE_COMPARE, true, "Compare number values strictly by type (real vs int)") + # inspector + create_property_if_need(INSPECTOR_NODE_COLLAPSE, true, + "Close testsuite node after a successful test run.") + create_property_if_need(INSPECTOR_TREE_VIEW_MODE, GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE, + "Inspector panel presentation mode", GdUnitInspectorTreeConstants.TREE_VIEW_MODE.keys()) + create_property_if_need(INSPECTOR_TREE_SORT_MODE, GdUnitInspectorTreeConstants.SORT_MODE.UNSORTED, + "Inspector panel sorting mode", GdUnitInspectorTreeConstants.SORT_MODE.keys()) + create_property_if_need(INSPECTOR_TOOLBAR_BUTTON_RUN_OVERALL, false, + "Show 'Run overall Tests' button in the inspector toolbar") + create_property_if_need(TEMPLATE_TS_GD, GdUnitTestSuiteTemplate.default_GD_template(), "Test suite template to use") + create_shortcut_properties_if_need() + create_property_if_need(SESSION_HOOKS, {} as Dictionary[String,bool]) + + +static func create_shortcut_properties_if_need() -> void: + # inspector + create_property_if_need(SHORTCUT_INSPECTOR_RERUN_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RERUN_TESTS), "Rerun the most recently executed tests") + create_property_if_need(SHORTCUT_INSPECTOR_RERUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RERUN_TESTS_DEBUG), "Rerun the most recently executed tests (Debug mode)") + create_property_if_need(SHORTCUT_INSPECTOR_RERUN_TEST_UNTIL_FAILURE, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RERUN_TESTS_UNTIL_FAILURE), "Rerun tests until failure occurs") + create_property_if_need(SHORTCUT_INSPECTOR_RUN_TEST_OVERALL, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTS_OVERALL), "Runs all tests (Debug mode)") + create_property_if_need(SHORTCUT_INSPECTOR_RUN_TEST_STOP, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.STOP_TEST_RUN), "Stop the current test execution") + # script editor + create_property_if_need(SHORTCUT_EDITOR_RUN_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTCASE), "Run the currently selected test") + create_property_if_need(SHORTCUT_EDITOR_RUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTCASE_DEBUG), "Run the currently selected test (Debug mode).") + create_property_if_need(SHORTCUT_EDITOR_CREATE_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.CREATE_TEST), "Create a new test case for the currently selected function") + # filesystem + create_property_if_need(SHORTCUT_FILESYSTEM_RUN_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTSUITE), "Run all test suites in the selected folder or file") + create_property_if_need(SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTSUITE_DEBUG), "Run all test suites in the selected folder or file (Debug)") + + +static func create_property_if_need( + property_name: String, + default_value: Variant, + help_text := "", + value_set := PackedStringArray()) -> void: + + if not ProjectSettings.has_setting(property_name): + #prints("GdUnit4: Set inital settings '%s' to '%s'." % [name, str(default)]) + ProjectSettings.set_setting(property_name, default_value) + + ProjectSettings.set_initial_value(property_name, default_value) + set_property_info(property_name, default_value, value_set) + set_property_help(property_name, help_text) + + +static func set_property_info(property_name: String, value: Variant, value_set: PackedStringArray) -> void: + var info := { + "name": property_name, + "type": typeof(value), + "hint": PROPERTY_HINT_NONE, + "hint_string": "", + } + if not value_set.is_empty(): + info["hint"] = PROPERTY_HINT_ENUM + info["hint_string"] = ",".join(value_set) + ProjectSettings.add_property_info(info) + + +static func set_property_help(property_name: String, help_text: String) -> void: + _property_help[property_name] = help_text + + +static func get_setting(name :String, default :Variant) -> Variant: + if ProjectSettings.has_setting(name): + return ProjectSettings.get_setting(name) + return default + + +static func is_update_notification_enabled() -> bool: + if ProjectSettings.has_setting(UPDATE_NOTIFICATION_ENABLED): + return ProjectSettings.get_setting(UPDATE_NOTIFICATION_ENABLED) + return false + + +static func set_update_notification(enable :bool) -> void: + ProjectSettings.set_setting(UPDATE_NOTIFICATION_ENABLED, enable) + @warning_ignore("return_value_discarded") + ProjectSettings.save() + + +static func get_log_path() -> String: + return ProjectSettings.get_setting(STDOUT_WITE_TO_FILE) + + +static func set_log_path(path :String) -> void: + ProjectSettings.set_setting(STDOUT_ENABLE_TO_FILE, true) + ProjectSettings.set_setting(STDOUT_WITE_TO_FILE, path) + @warning_ignore("return_value_discarded") + ProjectSettings.save() + + +static func get_session_hooks() -> Dictionary[String, bool]: + var property := get_property(SESSION_HOOKS) + if property == null: + return {} + var hooks: Dictionary[String, bool] = property.value() + return hooks + + +static func set_session_hooks(hooks: Dictionary[String, bool]) -> void: + var property := get_property(SESSION_HOOKS) + property.set_value(hooks) + update_property(property) + + +static func set_inspector_tree_sort_mode(sort_mode: GdUnitInspectorTreeConstants.SORT_MODE) -> void: + var property := get_property(INSPECTOR_TREE_SORT_MODE) + property.set_value(sort_mode) + update_property(property) + + +static func get_inspector_tree_sort_mode() -> GdUnitInspectorTreeConstants.SORT_MODE: + var property := get_property(INSPECTOR_TREE_SORT_MODE) + return property.value() if property != null else GdUnitInspectorTreeConstants.SORT_MODE.UNSORTED + + +static func set_inspector_tree_view_mode(tree_view_mode: GdUnitInspectorTreeConstants.TREE_VIEW_MODE) -> void: + var property := get_property(INSPECTOR_TREE_VIEW_MODE) + property.set_value(tree_view_mode) + update_property(property) + + +static func get_inspector_tree_view_mode() -> GdUnitInspectorTreeConstants.TREE_VIEW_MODE: + var property := get_property(INSPECTOR_TREE_VIEW_MODE) + return property.value() if property != null else GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE + + +# the configured server connection timeout in ms +static func server_timeout() -> int: + return get_setting(SERVER_TIMEOUT, DEFAULT_SERVER_TIMEOUT) * 60 * 1000 + + +# the configured test case timeout in ms +static func test_timeout() -> int: + return get_setting(TEST_TIMEOUT, DEFAULT_TEST_TIMEOUT) * 1000 + + +# the root folder to store/generate test-suites +static func test_root_folder() -> String: + return get_setting(TEST_LOOKUP_FOLDER, DEFAULT_TEST_LOOKUP_FOLDER) + + +static func is_verbose_assert_warnings() -> bool: + return get_setting(REPORT_ASSERT_WARNINGS, true) + + +static func is_verbose_assert_errors() -> bool: + return get_setting(REPORT_ASSERT_ERRORS, true) + + +static func is_verbose_orphans() -> bool: + return get_setting(REPORT_ORPHANS, true) + + +static func is_strict_number_type_compare() -> bool: + return get_setting(REPORT_ASSERT_STRICT_NUMBER_TYPE_COMPARE, true) + + +static func is_report_push_errors() -> bool: + return get_setting(REPORT_PUSH_ERRORS, false) + + +static func is_report_script_errors() -> bool: + return get_setting(REPORT_SCRIPT_ERRORS, true) + + +static func is_inspector_node_collapse() -> bool: + return get_setting(INSPECTOR_NODE_COLLAPSE, true) + + +static func is_inspector_toolbar_button_show() -> bool: + return get_setting(INSPECTOR_TOOLBAR_BUTTON_RUN_OVERALL, true) + + +static func is_test_discover_enabled() -> bool: + return get_setting(TEST_DISCOVER_ENABLED, false) + + +static func is_test_flaky_check_enabled() -> bool: + return get_setting(TEST_FLAKY_CHECK, false) + + +## Returns whether the framework automatically saves and restores the project settings +## around each test execution. When disabled, use [method GdUnitTestSuite.save_project_settings] +## and [method GdUnitTestSuite.restore_project_settings] to snapshot settings on demand. +static func is_project_settings_auto_save() -> bool: + return get_setting(TEST_PROJECT_SETTINGS_AUTO_SAVE, true) + + +static func is_feature_enabled(feature: String) -> bool: + return get_setting(feature, false) + + +static func get_flaky_max_retries() -> int: + return get_setting(TEST_FLAKY_MAX_RETRIES, 3) + + +static func get_rerun_max_retries() -> int: + return get_setting(TEST_RERUN_UNTIL_FAILURE_RETRIES, 10) + + +static func set_test_discover_enabled(enable :bool) -> void: + var property := get_property(TEST_DISCOVER_ENABLED) + property.set_value(enable) + update_property(property) + + +static func is_log_enabled() -> bool: + return ProjectSettings.get_setting(STDOUT_ENABLE_TO_FILE) + + +static func validate_is_inferred_declaration_enabled() -> GdUnitResult: + if ProjectSettings.get_setting(GDSCRIPT_WARNINGS_INFERRED_DECLARATION) == GdScriptWarningMode.IGNORE: + return GdUnitResult.success() + + if Engine.get_version_info().hex >= 0x40600: + var directory_rules: Dictionary = ProjectSettings.get_setting(GDSCRIPT_WARNINGS_DIRECTORY_RULES) + # Find the most specific matching rule (longest path wins) + var best_match := "" + for path: String in directory_rules.keys(): + if "res://addons/gdUnit4".begins_with(path) and path.length() > best_match.length(): + best_match = path + var is_excluded :bool = not best_match.is_empty() and directory_rules[best_match] == GdScriptWarningDirectoryMode.EXCLUDE + if not is_excluded: + return GdUnitResult.error(""" + GdUnit4: 'inferred_declaration' is set to Warning/Error! + GdUnit4 is not 'inferred_declaration' safe, you have to exclude the addon (debug/gdscript/warnings/directory_rules) + """.dedent().strip_edges()) + else: + if not ProjectSettings.get_setting(GDSCRIPT_WARNINGS_EXCLUDE_ADDONS): + return GdUnitResult.error(""" + GdUnit4: 'inferred_declaration' is set to Warning/Error! + GdUnit4 is not 'inferred_declaration' safe, you have to exclude addons (debug/gdscript/warnings/exclude_addons) + """.dedent().strip_edges()) + return GdUnitResult.success() + + +static func list_settings(category: String) -> Array[GdUnitProperty]: + var settings: Array[GdUnitProperty] = [] + for property in ProjectSettings.get_property_list(): + var property_name :String = property["name"] + if property_name.begins_with(category): + settings.append(build_property(property_name, property)) + return settings + + +static func update_property(property :GdUnitProperty) -> Variant: + var current_value :Variant = ProjectSettings.get_setting(property.name()) + if current_value != property.value(): + var error :Variant = validate_property_value(property) + if error != null: + return error + ProjectSettings.set_setting(property.name(), property.value()) + GdUnitSignals.instance().gdunit_settings_changed.emit(property) + _save_settings() + return null + + +static func reset_property(property :GdUnitProperty) -> void: + ProjectSettings.set_setting(property.name(), property.default()) + GdUnitSignals.instance().gdunit_settings_changed.emit(property) + _save_settings() + + +static func validate_property_value(property :GdUnitProperty) -> Variant: + match property.name(): + TEST_LOOKUP_FOLDER: + return validate_lookup_folder(property.value_as_string()) + _: return null + + +static func validate_lookup_folder(value :String) -> Variant: + if value.is_empty() or value == "/": + return null + if value.contains("res:"): + return "Test Lookup Folder: do not allowed to contains 'res://'" + if not value.is_valid_filename(): + return "Test Lookup Folder: contains invalid characters! e.g (: / \\ ? * \" | % < >)" + return null + + +static func save_property(name :String, value :Variant) -> void: + ProjectSettings.set_setting(name, value) + _save_settings() + + +static func _save_settings() -> void: + var err := ProjectSettings.save() + if err != OK: + push_error("Save GdUnit4 settings failed : %s" % error_string(err)) + return + + +static func has_property(name :String) -> bool: + return ProjectSettings.get_property_list().any(func(property :Dictionary) -> bool: return property["name"] == name) + + +static func get_property(name :String) -> GdUnitProperty: + for property in ProjectSettings.get_property_list(): + var property_name :String = property["name"] + if property_name == name: + return build_property(name, property) + return null + + +static func build_property(property_name: String, property: Dictionary) -> GdUnitProperty: + var value: Variant = ProjectSettings.get_setting(property_name) + var value_type: int = property["type"] + var default: Variant = ProjectSettings.property_get_revert(property_name) + var hint_string: String = property["hint_string"] + var value_set := PackedStringArray() if hint_string.is_empty() else hint_string.split(",") + var help_text :String = _property_help.get(property_name, "") + return GdUnitProperty.new(property_name, value_type, value, default, help_text, value_set) + + +static func migrate_property(old_property :String, new_property :String, default_value :Variant, help :String, converter := Callable()) -> void: + var property := get_property(old_property) + if property == null: + prints("Migration not possible, property '%s' not found" % old_property) + return + var value :Variant = converter.call(property.value()) if converter.is_valid() else property.value() + ProjectSettings.set_setting(new_property, value) + ProjectSettings.set_initial_value(new_property, default_value) + set_property_help(new_property, help) + set_property_info(new_property, value, []) + ProjectSettings.clear(old_property) + prints("Successfully migrated property '%s' -> '%s' value: %s" % [old_property, new_property, value]) diff --git a/addons/gdUnit4/src/core/GdUnitSettings.gd.uid b/addons/gdUnit4/src/core/GdUnitSettings.gd.uid new file mode 100644 index 0000000..9ff198e --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSettings.gd.uid @@ -0,0 +1 @@ +uid://dmpu34d1ogg7u diff --git a/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd new file mode 100644 index 0000000..528e133 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd @@ -0,0 +1,81 @@ +class_name GdUnitSignalAwaiter +extends RefCounted + +signal signal_emitted(action :Variant) + +const NO_ARG :Variant = GdUnitConstants.NO_ARG + +var _wait_on_idle_frame := false +var _interrupted := false +var _time_left :float = 0 +var _timeout_millis :int + + +func _init(timeout_millis :int, wait_on_idle_frame := false) -> void: + _timeout_millis = timeout_millis + _wait_on_idle_frame = wait_on_idle_frame + + +func _on_signal_emmited( + arg0 :Variant = NO_ARG, + arg1 :Variant = NO_ARG, + arg2 :Variant = NO_ARG, + arg3 :Variant = NO_ARG, + arg4 :Variant = NO_ARG, + arg5 :Variant = NO_ARG, + arg6 :Variant = NO_ARG, + arg7 :Variant = NO_ARG, + arg8 :Variant = NO_ARG, + arg9 :Variant = NO_ARG) -> void: + var signal_args :Variant = GdArrayTools.filter_value([arg0,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9], NO_ARG) + signal_emitted.emit(signal_args) + + +func is_interrupted() -> bool: + return _interrupted + + +func elapsed_time() -> float: + return _time_left + + +func on_signal(source :Object, signal_name :String, expected_signal_args :Array) -> Variant: + # register checked signal to wait for + @warning_ignore("return_value_discarded") + source.connect(signal_name, _on_signal_emmited) + # install timeout timer + var scene_tree := Engine.get_main_loop() as SceneTree + var timer := Timer.new() + scene_tree.root.add_child(timer) + timer.add_to_group("GdUnitTimers") + timer.set_one_shot(true) + @warning_ignore("return_value_discarded") + timer.timeout.connect(_do_interrupt, CONNECT_DEFERRED) + timer.start(_timeout_millis * 0.001 * Engine.get_time_scale()) + + # holds the emited value + var value :Variant + # wait for signal is emitted or a timeout is happen + while true: + value = await signal_emitted + if _interrupted: + break + if not (value is Array): + value = [value] + if expected_signal_args.size() == 0 or GdObjects.equals(value, expected_signal_args): + break + await scene_tree.process_frame + + source.disconnect(signal_name, _on_signal_emmited) + _time_left = timer.time_left + timer.queue_free() + await scene_tree.process_frame + @warning_ignore("unsafe_cast") + if value is Array and (value as Array).size() == 1: + return value[0] + return value + + +func _do_interrupt() -> void: + _interrupted = true + signal_emitted.emit(null) diff --git a/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid new file mode 100644 index 0000000..46594bb --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid @@ -0,0 +1 @@ +uid://duhwpd0mkvpeo diff --git a/addons/gdUnit4/src/core/GdUnitSignalCollector.gd b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd new file mode 100644 index 0000000..0cb6ad7 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd @@ -0,0 +1,129 @@ +# It connects to all signals of given emitter and collects received signals and arguments +# The collected signals are cleand finally when the emitter is freed. +class_name GdUnitSignalCollector +extends RefCounted + +const NO_ARG :Variant = GdUnitConstants.NO_ARG +const SIGNAL_BLACK_LIST = []#["tree_exiting", "tree_exited", "child_exiting_tree"] + +# { +# emitter : { +# signal_name : [signal_args], +# ... +# } +# } +var _collected_signals :Dictionary = {} + + +func clear() -> void: + for emitter :Object in _collected_signals.keys(): + if is_instance_valid(emitter): + unregister_emitter(emitter) + + +# connect to all possible signals defined by the emitter +# prepares the signal collection to store received signals and arguments +func register_emitter(emitter: Object, force_recreate := false) -> void: + if is_instance_valid(emitter): + # check emitter is already registerd + if _collected_signals.has(emitter): + if not force_recreate: + return + # If the flag recreate is set to true, emitters that are already registered must be deregistered before recreating, + # otherwise signals that have already been collected will be evaluated. + unregister_emitter(emitter) + + _collected_signals[emitter] = Dictionary() + # connect to 'tree_exiting' of the emitter to finally release all acquired resources/connections. + if emitter is Node and !(emitter as Node).tree_exiting.is_connected(unregister_emitter): + (emitter as Node).tree_exiting.connect(unregister_emitter.bind(emitter)) + # connect to all signals of the emitter we want to collect + for signal_def in emitter.get_signal_list(): + var signal_name :String = signal_def["name"] + # set inital collected to empty + if not is_signal_collecting(emitter, signal_name): + _collected_signals[emitter][signal_name] = Array() + if SIGNAL_BLACK_LIST.find(signal_name) != -1: + continue + if !emitter.is_connected(signal_name, _on_signal_emmited): + var err := emitter.connect(signal_name, _on_signal_emmited.bind(emitter, signal_name)) + if err != OK: + push_error("Can't connect to signal %s on %s. Error: %s" % [signal_name, emitter, error_string(err)]) + + +# unregister all acquired resources/connections, otherwise it ends up in orphans +# is called when the emitter is removed from the parent +func unregister_emitter(emitter :Object) -> void: + if is_instance_valid(emitter): + for signal_def in emitter.get_signal_list(): + var signal_name :String = signal_def["name"] + if emitter.is_connected(signal_name, _on_signal_emmited): + emitter.disconnect(signal_name, _on_signal_emmited.bind(emitter, signal_name)) + @warning_ignore("return_value_discarded") + _collected_signals.erase(emitter) + + +# receives the signal from the emitter with all emitted signal arguments and additional the emitter and signal_name as last two arguements +func _on_signal_emmited( + arg0 :Variant= NO_ARG, + arg1 :Variant= NO_ARG, + arg2 :Variant= NO_ARG, + arg3 :Variant= NO_ARG, + arg4 :Variant= NO_ARG, + arg5 :Variant= NO_ARG, + arg6 :Variant= NO_ARG, + arg7 :Variant= NO_ARG, + arg8 :Variant= NO_ARG, + arg9 :Variant= NO_ARG, + arg10 :Variant= NO_ARG, + arg11 :Variant= NO_ARG) -> void: + var signal_args :Array = GdArrayTools.filter_value([arg0,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9,arg10,arg11], NO_ARG) + # extract the emitter and signal_name from the last two arguments (see line 61 where is added) + var signal_name :String = signal_args.pop_back() + var emitter :Object = signal_args.pop_back() + #prints("_on_signal_emmited:", emitter, signal_name, signal_args) + if is_signal_collecting(emitter, signal_name): + @warning_ignore("unsafe_cast") + (_collected_signals[emitter][signal_name] as Array).append(signal_args) + + +func reset_received_signals(emitter: Object, signal_name: String, signal_args: Array) -> void: + #_debug_signal_list("before claer"); + if _collected_signals.has(emitter): + var signals_by_emitter :Dictionary = _collected_signals[emitter] + if signals_by_emitter.has(signal_name): + var received_args: Array = _collected_signals[emitter][signal_name] + # We iterate backwarts over to received_args to remove matching args. + # This will avoid array corruption see comment on `erase` otherwise we need a timeconsuming duplicate before + for arg_pos: int in range(received_args.size()-1, -1, -1): + var arg: Variant = received_args[arg_pos] + if GdObjects.equals(arg, signal_args): + received_args.remove_at(arg_pos) + #_debug_signal_list("after claer"); + + +func is_signal_collecting(emitter: Object, signal_name: String) -> bool: + @warning_ignore("unsafe_cast") + return _collected_signals.has(emitter) and (_collected_signals[emitter] as Dictionary).has(signal_name) + + +func match(emitter: Object, signal_name: String, args: Array) -> bool: + #prints("match", signal_name, _collected_signals[emitter][signal_name]); + if _collected_signals.is_empty() or not _collected_signals.has(emitter): + return false + for received_args :Variant in _collected_signals[emitter][signal_name]: + #prints("testing", signal_name, received_args, "vs", args) + if GdObjects.equals(received_args, args): + return true + return false + + +func _debug_signal_list(message :String) -> void: + prints("-----", message, "-------") + prints("senders {") + for emitter :Object in _collected_signals: + prints("\t", emitter) + for signal_name :String in _collected_signals[emitter]: + var args :Variant = _collected_signals[emitter][signal_name] + prints("\t\t", signal_name, args) + prints("}") diff --git a/addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid new file mode 100644 index 0000000..07a0241 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid @@ -0,0 +1 @@ +uid://ci68yhn1qqqon diff --git a/addons/gdUnit4/src/core/GdUnitSignals.gd b/addons/gdUnit4/src/core/GdUnitSignals.gd new file mode 100644 index 0000000..f0a900d --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSignals.gd @@ -0,0 +1,118 @@ +class_name GdUnitSignals +extends RefCounted +## Singleton class that handles GdUnit's signal communication.[br] +## [br] +## This class manages all signals used to communicate test events, discovery, and status changes.[br] +## It uses a singleton pattern stored in Engine metadata to ensure a single instance.[br] +## [br] +## Signals are grouped by purpose:[br] +## - Client connection handling[br] +## - Test execution events[br] +## - Test discovery events[br] +## - Settings and status updates[br] +## [br] +## Example usage:[br] +## [codeblock] +## # Connect to test discovery +## GdUnitSignals.instance().gdunit_test_discovered.connect(self._on_test_discovered) +## +## # Emit test event +## GdUnitSignals.instance().gdunit_event.emit(test_event) +## [/codeblock] + + +## Emitted when a client connects to the GdUnit server.[br] +## [param client_id] The ID of the connected client. +@warning_ignore("unused_signal") +signal gdunit_client_connected(client_id: int) + + +## Emitted when a client disconnects from the GdUnit server.[br] +## [param client_id] The ID of the disconnected client. +@warning_ignore("unused_signal") +signal gdunit_client_disconnected(client_id: int) + + +## Emitted when a the user stops (terminates) the current test session +@warning_ignore("unused_signal") +signal gdunit_test_session_terminate() + + +## Emitted when a test execution event occurs.[br] +## [param event] The test event containing details about test execution. +@warning_ignore("unused_signal") +signal gdunit_event(event: GdUnitEvent) + + +## Emitted for test debug events during execution.[br] +## [param event] The debug event containing test execution details. +@warning_ignore("unused_signal") +signal gdunit_event_debug(event: GdUnitEvent) + + +## Emitted to broadcast a general message.[br] +## [param message] The message to broadcast. +@warning_ignore("unused_signal") +signal gdunit_message(message: String) + + +## Emitted to update test failure status.[br] +## [param is_failed] Whether the test has failed. +@warning_ignore("unused_signal") +signal gdunit_set_test_failed(is_failed: bool) + + +## Emitted when a GdUnit setting changes.[br] +## [param property] The property that was changed. +@warning_ignore("unused_signal") +signal gdunit_settings_changed(property: GdUnitProperty) + +## Called when a new test case is discovered during the discovery process. +## Custom implementations should connect to this signal and store the discovered test case as needed.[br] +## [param test_case] The discovered test case instance to be processed. +@warning_ignore("unused_signal") +signal gdunit_test_discover_added(test_case: GdUnitTestCase) + + +## Emitted when a test case is deleted.[br] +## [param test_case] The test case that was deleted. +@warning_ignore("unused_signal") +signal gdunit_test_discover_deleted(test_case: GdUnitTestCase) + + +## Emitted when a test case is modified.[br] +## [param test_case] The test case that was modified. +@warning_ignore("unused_signal") +signal gdunit_test_discover_modified(test_case: GdUnitTestCase) + + +const META_KEY := "GdUnitSignals" + + +## Returns the singleton instance of GdUnitSignals.[br] +## Creates a new instance if none exists.[br] +## [br] +## Returns: The GdUnitSignals singleton instance. +static func instance() -> GdUnitSignals: + if Engine.has_meta(META_KEY): + return Engine.get_meta(META_KEY) + var instance_ := GdUnitSignals.new() + Engine.set_meta(META_KEY, instance_) + return instance_ + + +## Cleans up the singleton instance and disconnects all signals.[br] +## [br] +## Should be called when GdUnit is shutting down or needs to reset.[br] +## Ensures proper cleanup of signal connections and resources. +static func dispose() -> void: + var signals := instance() + # cleanup connected signals + for signal_ in signals.get_signal_list(): + @warning_ignore("unsafe_cast") + for connection in signals.get_signal_connection_list(signal_["name"] as StringName): + var _signal: Signal = connection["signal"] + var _callable: Callable = connection["callable"] + _signal.disconnect(_callable) + signals = null + Engine.remove_meta(META_KEY) diff --git a/addons/gdUnit4/src/core/GdUnitSignals.gd.uid b/addons/gdUnit4/src/core/GdUnitSignals.gd.uid new file mode 100644 index 0000000..05c3188 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSignals.gd.uid @@ -0,0 +1 @@ +uid://bqodofbxh6w82 diff --git a/addons/gdUnit4/src/core/GdUnitSingleton.gd b/addons/gdUnit4/src/core/GdUnitSingleton.gd new file mode 100644 index 0000000..b8e08cc --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSingleton.gd @@ -0,0 +1,56 @@ +################################################################################ +# Provides access to a global accessible singleton +# +# This is a workarount to the existing auto load singleton because of some bugs +# around plugin handling +################################################################################ +class_name GdUnitSingleton +extends Object + + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const MEATA_KEY := "GdUnitSingletons" + + +static func instance(name: String, clazz: Callable) -> Variant: + if Engine.has_meta(name): + return Engine.get_meta(name) + var singleton: Variant = clazz.call() + if is_instance_of(singleton, RefCounted): + @warning_ignore("unsafe_cast") + push_error("Invalid singleton implementation detected for '%s' is `%s`!" % [name, (singleton as RefCounted).get_class()]) + return + + Engine.set_meta(name, singleton) + GdUnitTools.prints_verbose("Register singleton '%s:%s'" % [name, singleton]) + var singletons: PackedStringArray = Engine.get_meta(MEATA_KEY, PackedStringArray()) + @warning_ignore("return_value_discarded") + singletons.append(name) + Engine.set_meta(MEATA_KEY, singletons) + return singleton + + +static func unregister(p_singleton: String, use_call_deferred: bool = false) -> void: + var singletons: PackedStringArray = Engine.get_meta(MEATA_KEY, PackedStringArray()) + if singletons.has(p_singleton): + GdUnitTools.prints_verbose("\n Unregister singleton '%s'" % p_singleton); + var index := singletons.find(p_singleton) + singletons.remove_at(index) + var instance_: Object = Engine.get_meta(p_singleton) + GdUnitTools.prints_verbose(" Free singleton instance '%s:%s'" % [p_singleton, instance_]) + @warning_ignore("return_value_discarded") + GdUnitTools.free_instance(instance_, use_call_deferred) + Engine.remove_meta(p_singleton) + GdUnitTools.prints_verbose(" Successfully freed '%s'" % p_singleton) + Engine.set_meta(MEATA_KEY, singletons) + + +static func dispose(use_call_deferred: bool = false) -> void: + # use a copy because unregister is modify the singletons array + var singletons: PackedStringArray = Engine.get_meta(MEATA_KEY, PackedStringArray()) + GdUnitTools.prints_verbose("----------------------------------------------------------------") + GdUnitTools.prints_verbose("Cleanup singletons %s" % singletons) + for singleton in PackedStringArray(singletons): + unregister(singleton, use_call_deferred) + Engine.remove_meta(MEATA_KEY) + GdUnitTools.prints_verbose("----------------------------------------------------------------") diff --git a/addons/gdUnit4/src/core/GdUnitSingleton.gd.uid b/addons/gdUnit4/src/core/GdUnitSingleton.gd.uid new file mode 100644 index 0000000..4a09b1d --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitSingleton.gd.uid @@ -0,0 +1 @@ +uid://5b6albhav7dr diff --git a/addons/gdUnit4/src/core/GdUnitStackTrace.gd b/addons/gdUnit4/src/core/GdUnitStackTrace.gd new file mode 100644 index 0000000..14f32c9 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitStackTrace.gd @@ -0,0 +1,116 @@ +## Captures and holds the test-side call stack at the point of an assertion failure.[br] +## Internal gdUnit4 frames are filtered out, leaving only user and test script frames. +class_name GdUnitStackTrace +extends RefCounted + +var _stack_trace: Array[GdUnitStackTraceElement] + + +static func from_script_backtraces(script_backtraces: Array[ScriptBacktrace]) -> GdUnitStackTrace: + var test_stack_trace: Array[GdUnitStackTraceElement] = [] + + for sb in script_backtraces: + for frame in sb.get_frame_count(): + var source := sb.get_frame_file(frame) + + if filter_sources(source): + continue + + var function := sb.get_frame_function(frame) + var line := sb.get_frame_line(frame) + test_stack_trace.append(GdUnitStackTraceElement.new(source, line, function)) + return GdUnitStackTrace.of(test_stack_trace) + + +func _init(stack_trace := _extract_test_stack_trace()) -> void: + _stack_trace = stack_trace + # Verify stacktrace + for stack_element in stack_trace: + assert(stack_element != null) + + +## Returns a newline-separated string of all frames in the stack trace. +func _to_string() -> String: + return "\n".join(_stack_trace) + + +## Returns all frames in the stack trace, ordered innermost first. +func get_frames() -> Array[GdUnitStackTraceElement]: + return _stack_trace + + +## Returns the line number of the topmost frame, or [code]-1[/code] if the stack trace is empty. +func get_line_number() -> int: + if _stack_trace.is_empty(): + return -1 + return _stack_trace.front()._line + + +## Returns a formatted multi-line string with each frame listed as [code]at - source:line in function 'name'[/code]. +func print_stack_trace() -> String: + var output := "" + for frame in _stack_trace: + if not output.is_empty(): + output += "\n" + if frame._function != null and not frame._function.is_empty(): + output += "\tat '%s' in %s:%d" % [frame._function, frame._source, frame._line] + else: + output += "\tat %s:%d" % [frame._source, frame._line] + return output + + +## Serializes the stack trace to a JSON string. +func serialize() -> String: + var frames: Array[Dictionary] = [] + for frame in _stack_trace: + frames.append({"source": frame._source, "line": frame._line, "function": frame._function}) + return JSON.stringify(frames) + + +## Reconstructs a [GdUnitStackTrace] from a JSON string produced by [method serialize]. +static func deserialize(json: String) -> GdUnitStackTrace: + var data: Variant = JSON.parse_string(json) + if not data is Array: + return GdUnitStackTrace.of([]) + var frames: Array[GdUnitStackTraceElement] = [] + for frame_data: Dictionary in data: + frames.append(GdUnitStackTraceElement.of(frame_data)) + return GdUnitStackTrace.of(frames) + + +## Creates a [GdUnitStackTrace] from an existing array of [GdUnitStackTraceElement] frames.[br] +## Useful for constructing expected stack traces in assertions. +static func of(stack_trace: Array[GdUnitStackTraceElement]) -> GdUnitStackTrace: + return GdUnitStackTrace.new(stack_trace) + + +## Captures the current GDScript call stack and filters out all internal gdUnit4 frames,[br] +## returning only frames from user scripts and test files. +static func _extract_test_stack_trace() -> Array[GdUnitStackTraceElement]: + var stack_trace: Array[Dictionary] = get_stack() + if stack_trace == null or stack_trace.is_empty(): + return [] + + var test_stack_trace: Array[GdUnitStackTraceElement] = [] + for index in range(stack_trace.size() - 1, -1, -1): + var stack_info := stack_trace[index] + var source: String = stack_info.get("source") + + if filter_sources(source): + continue + + var line: int = stack_info.get("line") + var function: String = stack_info.get("function") + + test_stack_trace.append(GdUnitStackTraceElement.new(source, line, function)) + + test_stack_trace.reverse() + return test_stack_trace + + + +static func filter_sources(source: String) -> bool: + return ( + source.begins_with("res://addons/gdUnit4/src/") + or source.begins_with("user://tmp/mock/") + or source.begins_with("user://tmp/spy/")) diff --git a/addons/gdUnit4/src/core/GdUnitStackTrace.gd.uid b/addons/gdUnit4/src/core/GdUnitStackTrace.gd.uid new file mode 100644 index 0000000..3d3dbb8 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitStackTrace.gd.uid @@ -0,0 +1 @@ +uid://bvprwr1av6hnr diff --git a/addons/gdUnit4/src/core/GdUnitStackTraceElement.gd b/addons/gdUnit4/src/core/GdUnitStackTraceElement.gd new file mode 100644 index 0000000..65df9cf --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitStackTraceElement.gd @@ -0,0 +1,32 @@ +## Represents a single frame in a GDScript call stack.[br] +## Stores the source file, line number, and function name at the point of capture. +class_name GdUnitStackTraceElement +extends RefCounted + + +## The resource path of the source file (e.g. [code]res://addons/my_plugin/foo.gd[/code]). +var _source: String +## The line number within [member _source]. +var _line: int +## The name of the function at this frame. +var _function: String + + +## Creates a new stack trace element from the given [param source] path, [param line] number, and [param function] name. +func _init(source: String, line: int, function: String) -> void: + _source = source + _line = line + _function = function + + +## Creates a [GdUnitStackTraceElement] from a dictionary with keys [code]source[/code], [code]line[/code], and [code]function[/code]. +static func of(data: Dictionary) -> GdUnitStackTraceElement: + var source: String = data["source"] + var line: int = data["line"] + var function: String = data["function"] + return GdUnitStackTraceElement.new(source, line, function) + + +## Returns a human-readable representation in the form [code]basename.function(file:line)[/code]. +func _to_string() -> String: + return "%s.%s(%s:%d)" % [_source.get_basename(), _function, _source.get_file(), _line] diff --git a/addons/gdUnit4/src/core/GdUnitStackTraceElement.gd.uid b/addons/gdUnit4/src/core/GdUnitStackTraceElement.gd.uid new file mode 100644 index 0000000..302d5aa --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitStackTraceElement.gd.uid @@ -0,0 +1 @@ +uid://gpnh3pra7p17 diff --git a/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd new file mode 100644 index 0000000..33b80e2 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd @@ -0,0 +1,97 @@ +class_name GdUnitTestResourceLoader +extends RefCounted + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +enum { + GD_SUITE, + CS_SUITE +} + + +static func load_test_suite(resource_path: String, script_type := GD_SUITE) -> Node: + match script_type: + GD_SUITE: + return load_test_suite_gd(resource_path) + CS_SUITE: + return load_test_suite_cs(resource_path) + assert("type '%s' is not implemented" % script_type) + return null + + +static func load_tests(resource_path: String) -> Dictionary: + var script := load_gd_script(resource_path) + var discovered_tests := {} + GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void: + discovered_tests[test.display_name] = test + ) + + return discovered_tests + + +static func load_test_suite_gd(resource_path: String) -> GdUnitTestSuite: + var script := load_gd_script(resource_path) + var discovered_tests: Array[GdUnitTestCase] = [] + GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void: + discovered_tests.append(test) + ) + # complete test suite wiht parsed test cases + return GdUnitTestSuiteScanner.new().load_suite(script, discovered_tests) + + +static func load_test_suite_cs(resource_path: String) -> Node: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return null + var script :Script = ClassDB.instantiate("CSharpScript") + script.source_code = GdUnitFileAccess.resource_as_string(resource_path) + script.resource_path = resource_path + script.reload() + return null + + +static func load_cs_script(resource_path: String, debug_write := false) -> Script: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return null + var script :Script = ClassDB.instantiate("CSharpScript") + script.source_code = GdUnitFileAccess.resource_as_string(resource_path) + var script_resource_path := resource_path.replace(resource_path.get_extension(), "cs") + if debug_write: + script_resource_path = GdUnitFileAccess.create_temp_dir("test") + "/%s" % script_resource_path.get_file() + print_debug("save resource:", script_resource_path) + DirAccess.remove_absolute(script_resource_path) + var err := ResourceSaver.save(script, script_resource_path) + if err != OK: + print_debug("Can't save debug resource",script_resource_path, "Error:", error_string(err)) + script.take_over_path(script_resource_path) + else: + script.take_over_path(resource_path) + script.reload() + return script + + +static func load_gd_script(resource_path: String, debug_write := false) -> GDScript: + # grap current level + var unsafe_method_access: Variant = ProjectSettings.get_setting("debug/gdscript/warnings/unsafe_method_access") + # disable and load the script + ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", 0) + + var script := GDScript.new() + script.source_code = GdUnitFileAccess.resource_as_string(resource_path) + var script_resource_path := resource_path.replace(resource_path.get_extension(), "gd") + if debug_write: + script_resource_path = script_resource_path.replace("res://", GdUnitFileAccess.temp_dir() + "/") + #print_debug("save resource: ", script_resource_path) + DirAccess.remove_absolute(script_resource_path) + DirAccess.make_dir_recursive_absolute(script_resource_path.get_base_dir()) + var err := ResourceSaver.save(script, script_resource_path, ResourceSaver.FLAG_REPLACE_SUBRESOURCE_PATHS) + if err != OK: + print_debug("Can't save debug resource", script_resource_path, "Error:", error_string(err)) + script.take_over_path(script_resource_path) + else: + script.take_over_path(resource_path) + var error := script.reload() + if error != OK: + push_error("Errors on loading script %s. Error: %s" % [resource_path, error_string(error)]) + ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", unsafe_method_access) + return script + #@warning_ignore("unsafe_cast") diff --git a/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid new file mode 100644 index 0000000..d03f8d7 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid @@ -0,0 +1 @@ +uid://dpoamq868spud diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd new file mode 100644 index 0000000..2364238 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd @@ -0,0 +1,21 @@ +class_name GdUnitTestSuiteBuilder +extends RefCounted + + +static func create(source :Script, line_number :int) -> GdUnitResult: + var test_suite_path := GdUnitTestSuiteScanner.resolve_test_suite_path(source.resource_path, GdUnitSettings.test_root_folder()) + # we need to save and close the testsuite and source if is current opened before modify + @warning_ignore("return_value_discarded") + GdUnitScriptEditorControls.save_and_close_script(source.resource_path, false) + @warning_ignore("return_value_discarded") + GdUnitScriptEditorControls.save_and_close_script(test_suite_path, true) + + if source.get_class() == "CSharpScript": + return GdUnit4CSharpApiLoader.create_test_suite(source.resource_path, line_number+1, test_suite_path) + var parser := GdScriptParser.new() + var lines := source.source_code.split("\n") + var current_line := lines[line_number] + var func_name := parser.parse_func_name(current_line) + if func_name.is_empty(): + return GdUnitResult.error("No function found at line: %d." % line_number) + return GdUnitTestSuiteScanner.create_test_case(test_suite_path, func_name, source.resource_path) diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid new file mode 100644 index 0000000..0c3d26a --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid @@ -0,0 +1 @@ +uid://d0y1tn68oxuvu diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd new file mode 100644 index 0000000..0931368 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd @@ -0,0 +1,421 @@ +class_name GdUnitTestSuiteScanner +extends RefCounted + +const TEST_FUNC_TEMPLATE =""" + +func test_${func_name}() -> void: + # remove this line and complete your test + assert_not_yet_implemented() +""" + + +# we exclude the gdunit source directorys by default +const exclude_scan_directories = [ + "res://addons/gdUnit4/bin", + "res://addons/gdUnit4/src", + "res://reports"] + + +const ARGUMENT_TIMEOUT := "timeout" +const ARGUMENT_SKIP := "do_skip" +const ARGUMENT_SKIP_REASON := "skip_reason" +const ARGUMENT_PARAMETER_SET := "test_parameters" + + +var _script_parser := GdScriptParser.new() +var _included_resources: PackedStringArray = [] +var _excluded_resources: PackedStringArray = [] +var _expression_runner := GdUnitExpressionRunner.new() +var _regex_extends_clazz_name := RegEx.create_from_string("extends[\\s]+([\\S]+)") + + +func prescan_testsuite_classes() -> void: + # scan and cache extends GdUnitTestSuite by class name an resource paths + var script_classes: Array[Dictionary] = ProjectSettings.get_global_class_list() + for script_meta in script_classes: + var base_class: String = script_meta["base"] + var resource_path: String = script_meta["path"] + if base_class == "GdUnitTestSuite": + @warning_ignore("return_value_discarded") + _included_resources.append(resource_path) + elif ClassDB.class_exists(base_class): + @warning_ignore("return_value_discarded") + _excluded_resources.append(resource_path) + + +func scan(resource_path: String) -> Array[Script]: + prescan_testsuite_classes() + # if single testsuite requested + if FileAccess.file_exists(resource_path): + var test_suite := _load_is_test_suite(resource_path) + if test_suite != null: + return [test_suite] + return [] + return scan_directory(resource_path) + + +func scan_directory(resource_path: String) -> Array[Script]: + prescan_testsuite_classes() + # We use the global cache to fast scan for test suites. + if _excluded_resources.has(resource_path): + return [] + + var base_dir := DirAccess.open(resource_path) + if base_dir == null: + prints("Given directory or file does not exists:", resource_path) + return [] + + prints("Scanning for test suites in:", resource_path) + return _scan_test_suites_scripts(base_dir, []) + + +func _scan_test_suites_scripts(dir: DirAccess, collected_suites: Array[Script]) -> Array[Script]: + # Skip excluded directories + if dir.file_exists(".gdignore"): + prints("Exclude directory %s, containing .gdignore file" % dir.get_current_dir()) + return [] + + if exclude_scan_directories.has(dir.get_current_dir()): + return collected_suites + + var err := dir.list_dir_begin() + if err != OK: + push_error("Error on scanning directory %s" % dir.get_current_dir(), error_string(err)) + return collected_suites + var file_name := dir.get_next() + while file_name != "": + var resource_path := GdUnitTestSuiteScanner._file(dir, file_name) + if dir.current_is_dir(): + var sub_dir := DirAccess.open(resource_path) + if sub_dir != null: + @warning_ignore("return_value_discarded") + _scan_test_suites_scripts(sub_dir, collected_suites) + else: + var time := LocalTime.now() + var test_suite := _load_is_test_suite(resource_path) + if test_suite: + collected_suites.append(test_suite) + if OS.is_stdout_verbose() and time.elapsed_since_ms() > 300: + push_warning("Scanning of test-suite '%s' took more than 300ms: " % resource_path, time.elapsed_since()) + file_name = dir.get_next() + return collected_suites + + +static func _file(dir: DirAccess, file_name: String) -> String: + var current_dir := dir.get_current_dir() + if current_dir.ends_with("/"): + return current_dir + file_name + return current_dir + "/" + file_name + + +func _load_is_test_suite(resource_path: String) -> Script: + if not GdUnitTestSuiteScanner._is_script_format_supported(resource_path): + return null + + # We use the global cache to fast scan for test suites. + if _excluded_resources.has(resource_path): + return null + # Check in the global class cache whether the GdUnitTestSuite class has been extended. + if _included_resources.has(resource_path): + return GdUnitTestSuiteScanner.load_with_disabled_warnings(resource_path) + + # Otherwise we need to scan manual, we need to exclude classes where direct extends form Godot classes + # the resource loader can fail to load e.g. plugin classes with do preload other scripts + #var extends_from := get_extends_classname(resource_path) + # If not extends is defined or extends from a Godot class + #if extends_from.is_empty() or ClassDB.class_exists(extends_from): + # return null + # Finally, we need to load the class to determine it is a test suite + var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(resource_path) + if not is_test_suite(script): + return null + return script + + +func load_suite(script: GDScript, tests: Array[GdUnitTestCase]) -> GdUnitTestSuite: + var test_suite: GdUnitTestSuite = script.new() + var first_test: GdUnitTestCase = tests.front() + test_suite.set_name(first_test.suite_name) + + # We need to group first all parameterized tests together to load the parameter set once + var grouped_by_test := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String: + return test.test_name + ) + # Extract function descriptors + var test_names: PackedStringArray = grouped_by_test.keys() + test_names.append("before") + var function_descriptors := _script_parser.get_function_descriptors(script, test_names) + + # Convert to test + for fd in function_descriptors: + if fd.name() == "before": + _handle_test_suite_arguments(test_suite, script, fd) + continue + + # Build test attributes and test parameter resolver from test function descriptor + var test_attribute := _build_test_attribute(script, fd) + var parameter_resolver := GdParameterSetResolverFactory.create(fd, test_suite) + + # Create test from descriptor and given attributes + var test_group: Array = grouped_by_test[fd.name()] + for test: GdUnitTestCase in test_group: + # We need a copy, because of mutable state + var attribute: TestCaseAttribute = test_attribute.clone() + test_suite.add_child(_TestCase.new(test, attribute, parameter_resolver)) + return test_suite + + +func _build_test_attribute(script: GDScript, fd: GdFunctionDescriptor) -> TestCaseAttribute: + var collected_unknown_aruments := PackedStringArray() + var seen_config_arguments := PackedStringArray() + var attribute := TestCaseAttribute.new() + + # Collect test attributes + for arg: GdFunctionArgument in fd.args(): + if arg.type() == GdObjects.TYPE_FUZZER: + if not seen_config_arguments.is_empty(): + attribute.is_skipped = true + attribute.skip_reason = ( + "Fuzzer argument '%s' must be declared before the test config argument(s) %s." + % [arg.name(), seen_config_arguments]) + return attribute + attribute.fuzzers.append(arg) + else: + # We allow underscore as prefix to prevent unused argument warnings + var arg_name := arg.name().trim_prefix("_") + seen_config_arguments.append(arg_name) + match arg_name: + ARGUMENT_TIMEOUT: + attribute.timeout = type_convert(arg.default(), TYPE_INT) + ARGUMENT_SKIP: + var result: Variant = _expression_runner.execute(script, arg.plain_value()) + if result is bool: + attribute.is_skipped = result + else: + push_error("Test expression '%s' cannot be evaluated because it is not of type bool!" % arg.plain_value()) + ARGUMENT_SKIP_REASON: + attribute.skip_reason = arg.plain_value() + Fuzzer.ARGUMENT_ITERATIONS: + attribute.fuzzer_iterations = type_convert(arg.default(), TYPE_INT) + Fuzzer.ARGUMENT_SEED: + attribute.test_seed = type_convert(arg.default(), TYPE_INT) + ARGUMENT_PARAMETER_SET: + collected_unknown_aruments.clear() + pass + _: + collected_unknown_aruments.append(arg.name()) + + # Verify for unknown arguments + if not collected_unknown_aruments.is_empty(): + attribute.is_skipped = true + attribute.skip_reason = "Unknown test case argument's %s found." % collected_unknown_aruments + + return attribute + + +# We load the test suites with disabled unsafe_method_access to avoid spamming loading errors +# `unsafe_method_access` will happen when using `assert_that` +static func load_with_disabled_warnings(resource_path: String) -> Script: + # grap current level + var unsafe_method_access: Variant = ProjectSettings.get_setting("debug/gdscript/warnings/unsafe_method_access") + + # disable and load the script + ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", 0) + + var script: Script = ( + GdUnitTestResourceLoader.load_gd_script(resource_path) if resource_path.ends_with("resource") + else ResourceLoader.load(resource_path)) + + # restore + ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", unsafe_method_access) + return script + + +static func is_test_suite(script: Script) -> bool: + if script is GDScript: + var stack := [script] + while not stack.is_empty(): + var current: Script = stack.pop_front() + var base: Script = current.get_base_script() + if base != null: + if base.resource_path.find("GdUnitTestSuite") != -1: + return true + stack.push_back(base) + elif script != null and script.get_class() == "CSharpScript": + return true + return false + + +static func _is_script_format_supported(resource_path: String) -> bool: + var ext := resource_path.get_extension() + return ext == "gd" or (ext == "cs" and ClassDB.class_exists("CSharpScript")) + + +static func parse_test_suite_name(script: Script) -> String: + return script.resource_path.get_file().replace(".gd", "") + + +func _handle_test_suite_arguments(test_suite: GdUnitTestSuite, script: GDScript, fd: GdFunctionDescriptor) -> void: + for arg in fd.args(): + # We allow underscore as prefix to prevent unused argument warnings + match arg.name().trim_prefix("_"): + ARGUMENT_SKIP: + var result: Variant = _expression_runner.execute(script, arg.plain_value()) + if result is bool: + test_suite.__is_skipped = result + else: + push_error("Test expression '%s' cannot be evaluated because it is not of type bool!" % arg.plain_value()) + ARGUMENT_SKIP_REASON: + test_suite.__skip_reason = arg.plain_value() + _: + push_error("Unsuported argument `%s` found on before() at '%s'!" % [arg.name(), script.resource_path]) + + +# converts given file name by configured naming convention +static func _to_naming_convention(file_name: String) -> String: + var nc :int = GdUnitSettings.get_setting(GdUnitSettings.TEST_SUITE_NAMING_CONVENTION, 0) + match nc: + GdUnitSettings.NAMING_CONVENTIONS.AUTO_DETECT: + if GdObjects.is_snake_case(file_name): + return GdObjects.to_snake_case(file_name + "Test") + return GdObjects.to_pascal_case(file_name + "Test") + GdUnitSettings.NAMING_CONVENTIONS.SNAKE_CASE: + return GdObjects.to_snake_case(file_name + "Test") + GdUnitSettings.NAMING_CONVENTIONS.PASCAL_CASE: + return GdObjects.to_pascal_case(file_name + "Test") + push_error("Unexpected case") + return "--" + + +static func resolve_test_suite_path(source_script_path: String, test_root_folder: String = "test") -> String: + var file_name := source_script_path.get_basename().get_file() + var suite_name := _to_naming_convention(file_name) + if test_root_folder.is_empty() or test_root_folder == "/": + return source_script_path.replace(file_name, suite_name) + + # is user tmp + if source_script_path.begins_with("user://tmp"): + return normalize_path(source_script_path.replace("user://tmp", "user://tmp/" + test_root_folder)).replace(file_name, suite_name) + + # at first look up is the script under a "src" folder located + var test_suite_path: String + var src_folder := source_script_path.find("/src/") + if src_folder != -1: + test_suite_path = source_script_path.replace("/src/", "/"+test_root_folder+"/") + else: + var paths := source_script_path.split("/", false) + # is a plugin script? + if paths[1] == "addons": + test_suite_path = "%s//addons/%s/%s" % [paths[0], paths[2], test_root_folder] + # rebuild plugin path + for index in range(3, paths.size()): + test_suite_path += "/" + paths[index] + else: + test_suite_path = paths[0] + "//" + test_root_folder + for index in range(1, paths.size()): + test_suite_path += "/" + paths[index] + return normalize_path(test_suite_path).replace(file_name, suite_name) + + +static func normalize_path(path: String) -> String: + return path.replace("///", "/") + + +static func create_test_suite(test_suite_path: String, source_path: String) -> GdUnitResult: + # create directory if not exists + if not DirAccess.dir_exists_absolute(test_suite_path.get_base_dir()): + var error_ := DirAccess.make_dir_recursive_absolute(test_suite_path.get_base_dir()) + if error_ != OK: + return GdUnitResult.error("Can't create directoy at: %s. Error code %s" % [test_suite_path.get_base_dir(), error_]) + var script := GDScript.new() + script.source_code = GdUnitTestSuiteTemplate.build_template(source_path) + var error := ResourceSaver.save(script, test_suite_path) + if error != OK: + return GdUnitResult.error("Can't create test suite at: %s. Error code %s" % [test_suite_path, error]) + return GdUnitResult.success(test_suite_path) + + +static func get_test_case_line_number(resource_path: String, func_name: String) -> int: + var file := FileAccess.open(resource_path, FileAccess.READ) + if file != null: + var line_number := 0 + while not file.eof_reached(): + var row := file.get_line() + line_number += 1 + # ignore comments and empty lines and not test functions + if row.begins_with("#") || row.length() == 0 || row.find("func test_") == -1: + continue + # abort if test case name found + if row.find("func") != -1 and row.find("test_" + func_name) != -1: + return line_number + return -1 + + +func get_extends_classname(resource_path: String) -> String: + var file := FileAccess.open(resource_path, FileAccess.READ) + if file != null: + while not file.eof_reached(): + var row := file.get_line() + # skip comments and empty lines + if row.begins_with("#") || row.length() == 0: + continue + # Stop at first function + if row.contains("func"): + return "" + var result := _regex_extends_clazz_name.search(row) + if result != null: + return result.get_string(1) + return "" + + +static func add_test_case(resource_path: String, func_name: String) -> GdUnitResult: + var script := load_with_disabled_warnings(resource_path) + # count all exiting lines and add two as space to add new test case + var line_number := count_lines(script) + 2 + var func_body := TEST_FUNC_TEMPLATE.replace("${func_name}", func_name) + if Engine.is_editor_hint(): + # NOTE: Avoid using EditorInterface and EditorSettings directly, + # as it causes compilation errors in exported projects. + @warning_ignore_start("unsafe_method_access") + var editor_interface: Object = Engine.get_singleton("EditorInterface") + var settings: Object = editor_interface.get_editor_settings() + var ident_type: int = settings.get_setting("text_editor/behavior/indent/type") + var ident_size: int = settings.get_setting("text_editor/behavior/indent/size") + @warning_ignore_restore("unsafe_method_access") + if ident_type == 1: + func_body = func_body.replace(" ", "".lpad(ident_size, " ")) + script.source_code += func_body + var error := ResourceSaver.save(script, resource_path) + if error != OK: + return GdUnitResult.error("Can't add test case at: %s to '%s'. Error code %s" % [func_name, resource_path, error]) + return GdUnitResult.success({ "path" : resource_path, "line" : line_number}) + + +static func count_lines(script: Script) -> int: + return script.source_code.split("\n").size() + + +static func test_suite_exists(test_suite_path: String) -> bool: + return FileAccess.file_exists(test_suite_path) + + +static func test_case_exists(test_suite_path :String, func_name :String) -> bool: + if not test_suite_exists(test_suite_path): + return false + var script := load_with_disabled_warnings(test_suite_path) + for f in script.get_script_method_list(): + if f["name"] == "test_" + func_name: + return true + return false + + +static func create_test_case(test_suite_path: String, func_name: String, source_script_path: String) -> GdUnitResult: + if test_case_exists(test_suite_path, func_name): + var line_number := get_test_case_line_number(test_suite_path, func_name) + return GdUnitResult.success({ "path" : test_suite_path, "line" : line_number}) + + if not test_suite_exists(test_suite_path): + var result := create_test_suite(test_suite_path, source_script_path) + if result.is_error(): + return result + return add_test_case(test_suite_path, func_name) diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid new file mode 100644 index 0000000..e35cc25 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid @@ -0,0 +1 @@ +uid://genrx8pm02d1 diff --git a/addons/gdUnit4/src/core/GdUnitTools.gd b/addons/gdUnit4/src/core/GdUnitTools.gd new file mode 100644 index 0000000..bf78da7 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTools.gd @@ -0,0 +1,145 @@ +extends RefCounted + + +static var _richtext_normalize: RegEx + + +static func normalize_text(text :String) -> String: + return text.replace("\r", ""); + + +static func richtext_normalize(input :String) -> String: + if _richtext_normalize == null: + _richtext_normalize = to_regex("\\[/?(b|color|bgcolor|right|table|cell).*?\\]") + return _richtext_normalize.sub(input, "", true).replace("\r", "") + + +static func to_regex(pattern :String) -> RegEx: + var regex := RegEx.new() + var err := regex.compile(pattern) + if err != OK: + push_error("Can't compiling regx '%s'.\n ERROR: %s" % [pattern, error_string(err)]) + return regex + + +static func prints_verbose(message :String) -> void: + if OS.is_stdout_verbose(): + prints(message) + + +static func free_instance(instance :Variant, use_call_deferred :bool = false, is_stdout_verbose := false) -> bool: + if instance is Array: + var as_array: Array = instance + for element: Variant in as_array: + @warning_ignore("return_value_discarded") + free_instance(element) + as_array.clear() + return true + # do not free an already freed instance + if not is_instance_valid(instance): + return false + # do not free a class refernece + @warning_ignore("unsafe_cast") + if typeof(instance) == TYPE_OBJECT and (instance as Object).is_class("GDScriptNativeClass"): + return false + if is_stdout_verbose: + print_verbose("GdUnit4:gc():free instance ", instance) + @warning_ignore("unsafe_cast") + release_double(instance as Object) + if instance is RefCounted: + @warning_ignore("unsafe_cast") + (instance as RefCounted).notification(Object.NOTIFICATION_PREDELETE) + # If scene runner freed we explicit await all inputs are processed + if instance is GdUnitSceneRunnerImpl: + @warning_ignore("unsafe_cast") + await (instance as GdUnitSceneRunnerImpl).await_input_processed() + return true + else: + if instance is Timer: + var timer: Timer = instance + timer.stop() + if use_call_deferred: + timer.call_deferred("free") + else: + timer.free() + await (Engine.get_main_loop() as SceneTree).process_frame + return true + + @warning_ignore("unsafe_cast") + if instance is Node and (instance as Node).get_parent() != null: + var node: Node = instance + if is_stdout_verbose: + print_verbose("GdUnit4:gc():remove node from parent ", node.get_parent(), node) + if use_call_deferred: + node.get_parent().remove_child.call_deferred(node) + #instance.call_deferred("set_owner", null) + else: + node.get_parent().remove_child(node) + if is_stdout_verbose: + print_verbose("GdUnit4:gc():freeing `free()` the instance ", instance) + if use_call_deferred: + @warning_ignore("unsafe_cast") + (instance as Object).call_deferred("free") + else: + @warning_ignore("unsafe_cast") + (instance as Object).free() + return !is_instance_valid(instance) + + +static func _release_connections(instance :Object) -> void: + if is_instance_valid(instance): + # disconnect from all connected signals to force freeing, otherwise it ends up in orphans + for connection in instance.get_incoming_connections(): + var signal_ :Signal = connection["signal"] + var callable_ :Callable = connection["callable"] + #prints(instance, connection) + #prints("signal", signal_.get_name(), signal_.get_object()) + #prints("callable", callable_.get_object()) + if instance.has_signal(signal_.get_name()) and instance.is_connected(signal_.get_name(), callable_): + #prints("disconnect signal", signal_.get_name(), callable_) + instance.disconnect(signal_.get_name(), callable_) + release_timers() + + +static func release_timers() -> void: + # we go the new way to hold all gdunit timers in group 'GdUnitTimers' + var scene_tree := Engine.get_main_loop() as SceneTree + if scene_tree.root == null: + return + for node :Node in scene_tree.root.get_children(): + if is_instance_valid(node) and node.is_in_group("GdUnitTimers"): + if is_instance_valid(node): + scene_tree.root.remove_child.call_deferred(node) + (node as Timer).stop() + node.queue_free() + + +# the finally cleaup unfreed resources and singletons +static func dispose_all(use_call_deferred :bool = false) -> void: + prints("Run dispose test resources") + release_timers() + GdUnitSingleton.dispose(use_call_deferred) + GdUnitSignals.dispose() + + +# if instance an mock or spy we need manually freeing the self reference +static func release_double(instance :Object) -> void: + if instance.has_method("__release_double"): + instance.call("__release_double") + + + +static func find_test_case(test_suite: Node, test_case_name: String, index := -1) -> _TestCase: + for test_case: _TestCase in test_suite.get_children(): + if test_case.test_name() == test_case_name: + if index != -1: + if test_case._test_case.attribute_index != index: + continue + return test_case + return null + + +static func register_expect_interupted_by_timeout(test_suite: Node, test_case_name: String) -> void: + var test_case := find_test_case(test_suite, test_case_name) + if test_case: + test_case.expect_to_interupt() diff --git a/addons/gdUnit4/src/core/GdUnitTools.gd.uid b/addons/gdUnit4/src/core/GdUnitTools.gd.uid new file mode 100644 index 0000000..90d9462 --- /dev/null +++ b/addons/gdUnit4/src/core/GdUnitTools.gd.uid @@ -0,0 +1 @@ +uid://g5l8dhbpxfkg diff --git a/addons/gdUnit4/src/core/GodotVersionFixures.gd b/addons/gdUnit4/src/core/GodotVersionFixures.gd new file mode 100644 index 0000000..5299325 --- /dev/null +++ b/addons/gdUnit4/src/core/GodotVersionFixures.gd @@ -0,0 +1,11 @@ +## This service class contains helpers to wrap Godot functions and handle them carefully depending on the current Godot version +class_name GodotVersionFixures +extends RefCounted + + +# handle global_position fixed by https://github.com/godotengine/godot/pull/88473 +static func set_event_global_position(event: InputEventMouseMotion, global_position: Vector2) -> void: + if Engine.get_version_info().hex >= 0x40202 or Engine.get_version_info().hex == 0x40104: + event.global_position = event.position + else: + event.global_position = global_position diff --git a/addons/gdUnit4/src/core/GodotVersionFixures.gd.uid b/addons/gdUnit4/src/core/GodotVersionFixures.gd.uid new file mode 100644 index 0000000..a6b9452 --- /dev/null +++ b/addons/gdUnit4/src/core/GodotVersionFixures.gd.uid @@ -0,0 +1 @@ +uid://c3325kcqr8fth diff --git a/addons/gdUnit4/src/core/LocalTime.gd b/addons/gdUnit4/src/core/LocalTime.gd new file mode 100644 index 0000000..fabaaf6 --- /dev/null +++ b/addons/gdUnit4/src/core/LocalTime.gd @@ -0,0 +1,114 @@ +# This class provides Date/Time functionallity to Godot +class_name LocalTime +extends Resource + +enum TimeUnit { + DEFAULT = 0, + MILLIS = 1, + SECOND = 2, + MINUTE = 3, + HOUR = 4, + DAY = 5, + MONTH = 6, + YEAR = 7 +} + +const SECONDS_PER_MINUTE:int = 60 +const MINUTES_PER_HOUR:int = 60 +const HOURS_PER_DAY:int = 24 +const MILLIS_PER_SECOND:int = 1000 +const MILLIS_PER_MINUTE:int = MILLIS_PER_SECOND * SECONDS_PER_MINUTE +const MILLIS_PER_HOUR:int = MILLIS_PER_MINUTE * MINUTES_PER_HOUR + +var _time :int +var _hour :int +var _minute :int +var _second :int +var _millisecond :int + + +static func now() -> LocalTime: + return LocalTime.new(_get_system_time_msecs()) + + +static func of_unix_time(time_ms :int) -> LocalTime: + return LocalTime.new(time_ms) + + +static func local_time(hours :int, minutes :int, seconds :int, milliseconds :int) -> LocalTime: + return LocalTime.new(MILLIS_PER_HOUR * hours\ + + MILLIS_PER_MINUTE * minutes\ + + MILLIS_PER_SECOND * seconds\ + + milliseconds) + + +func elapsed_since() -> String: + return LocalTime.elapsed(LocalTime._get_system_time_msecs() - _time) + + +func elapsed_since_ms() -> int: + return LocalTime._get_system_time_msecs() - _time + + +func plus(time_unit :TimeUnit, value :int) -> LocalTime: + var addValue:int = 0 + match time_unit: + TimeUnit.MILLIS: + addValue = value + TimeUnit.SECOND: + addValue = value * MILLIS_PER_SECOND + TimeUnit.MINUTE: + addValue = value * MILLIS_PER_MINUTE + TimeUnit.HOUR: + addValue = value * MILLIS_PER_HOUR + @warning_ignore("return_value_discarded") + _init(_time + addValue) + return self + + +static func elapsed(p_time_ms :int) -> String: + var local_time_ := LocalTime.new(p_time_ms) + if local_time_._hour > 0: + return "%dh %dmin %ds %dms" % [local_time_._hour, local_time_._minute, local_time_._second, local_time_._millisecond] + if local_time_._minute > 0: + return "%dmin %ds %dms" % [local_time_._minute, local_time_._second, local_time_._millisecond] + if local_time_._second > 0: + return "%ds %dms" % [local_time_._second, local_time_._millisecond] + return "%dms" % local_time_._millisecond + + +# create from epoch timestamp in ms +func _init(time: int) -> void: + _time = time + @warning_ignore("integer_division") + _hour = (time / MILLIS_PER_HOUR) % 24 + @warning_ignore("integer_division") + _minute = (time / MILLIS_PER_MINUTE) % 60 + @warning_ignore("integer_division") + _second = (time / MILLIS_PER_SECOND) % 60 + _millisecond = time % 1000 + + +func hour() -> int: + return _hour + + +func minute() -> int: + return _minute + + +func second() -> int: + return _second + + +func millis() -> int: + return _millisecond + + +func _to_string() -> String: + return "%02d:%02d:%02d.%03d" % [_hour, _minute, _second, _millisecond] + + +# wraper to old OS.get_system_time_msecs() function +static func _get_system_time_msecs() -> int: + return Time.get_unix_time_from_system() * 1000 as int diff --git a/addons/gdUnit4/src/core/LocalTime.gd.uid b/addons/gdUnit4/src/core/LocalTime.gd.uid new file mode 100644 index 0000000..2b33055 --- /dev/null +++ b/addons/gdUnit4/src/core/LocalTime.gd.uid @@ -0,0 +1 @@ +uid://cwsfaosm28mec diff --git a/addons/gdUnit4/src/core/_TestCase.gd b/addons/gdUnit4/src/core/_TestCase.gd new file mode 100644 index 0000000..3ea6348 --- /dev/null +++ b/addons/gdUnit4/src/core/_TestCase.gd @@ -0,0 +1,237 @@ +class_name _TestCase +extends Node + +signal completed() + + +var _test_case: GdUnitTestCase +var _attribute: TestCaseAttribute +var _current_iteration: int = -1 +var _expect_to_interupt := false +var _timer: Timer +var _interupted := false +var _terminated := false +var _failed := false +var _parameter_resolver: GdParameterSetResolver +var _is_disposed := false +var _func_state: Variant + + +func _init(test_case: GdUnitTestCase, attribute: TestCaseAttribute, parameter_resolver: GdParameterSetResolver) -> void: + set_name(test_case.test_name) + _test_case = test_case + _attribute = attribute + _parameter_resolver = parameter_resolver + + +func execute(p_test_parameter := Array(), p_iteration := 0) -> void: + _failure_received(false) + _current_iteration = p_iteration - 1 + if _current_iteration == - 1: + _set_failure_handler() + set_timeout() + + if is_parameterized(): + execute_parameterized() + elif not p_test_parameter.is_empty(): + update_fuzzers(p_test_parameter, p_iteration) + _execute_test_case(test_name(), p_test_parameter) + else: + _execute_test_case(test_name(), []) + await completed + + +func execute_parameterized() -> void: + _failure_received(false) + set_timeout() + + var test_parameters := _resolve_test_paramaters(_test_case.attribute_index) + if is_skipped(): + test_completed.call_deferred() + return + await _execute_test_case(test_name(), test_parameters) + + +func _resolve_test_paramaters(index: int) -> Array: + var parameter_set := _parameter_resolver.get_parameters(get_parent(), index) + if parameter_set.is_empty(): + return [] + var result := _parameter_resolver.validate(parameter_set, index) + if result.is_error(): + do_skip(true, result.error_message()) + return [] + return parameter_set + + +func dispose() -> void: + if _is_disposed: + return + _is_disposed = true + Engine.remove_meta("GD_TEST_FAILURE") + stop_timer() + _remove_failure_handler() + _attribute.fuzzers.clear() + + +@warning_ignore("shadowed_variable_base_class", "redundant_await") +func _execute_test_case(name: String, test_parameter: Array) -> void: + # save the function state like GDScriptFunctionState to dispose at test timeout to prevent orphan state + _func_state = get_parent().callv(name, test_parameter) + await _func_state + # We need to call deferred the signal `completed` otherwise the current thread is blocked + test_completed.call_deferred() + + +func test_completed() -> void: + completed.emit() + + +func update_fuzzers(input_values: Array, iteration: int) -> void: + for fuzzer :Variant in input_values: + if fuzzer is Fuzzer: + fuzzer._iteration_index = iteration + 1 + + +func set_timeout() -> void: + if is_instance_valid(_timer): + return + var time: float = _attribute.timeout / 1000.0 + _timer = Timer.new() + add_child(_timer) + _timer.set_name("gdunit_test_case_timer_%d" % _timer.get_instance_id()) + @warning_ignore("return_value_discarded") + _timer.timeout.connect(do_interrupt, CONNECT_DEFERRED) + _timer.set_one_shot(true) + _timer.set_wait_time(time) + _timer.set_autostart(false) + _timer.start() + + +func do_interrupt() -> void: + _interupted = true + # We need to dispose manually the function state here + GdObjects.dispose_function_state(_func_state) + if not is_expect_interupted(): + var execution_context := GdUnitThreadManager.get_current_context().get_execution_context() + if is_fuzzed(): + execution_context.add_report(GdUnitReport.new()\ + .create(GdUnitReport.INTERUPTED, line_number(), GdAssertMessages.fuzzer_interuped(_current_iteration, "timedout"))) + else: + execution_context.add_report(GdUnitReport.new()\ + .create(GdUnitReport.INTERUPTED, line_number(), GdAssertMessages.test_timeout(_attribute.timeout))) + test_completed.call_deferred() + + +func do_terminate() -> void: + _terminated = true + # We need to dispose manually the function state here + GdObjects.dispose_function_state(_func_state) + var execution_context := GdUnitThreadManager.get_current_context().get_execution_context() + execution_context.add_report(GdUnitReport.new()\ + .create(GdUnitReport.TERMINATED, line_number(), GdAssertMessages.test_session_terminated())) + test_completed.call_deferred() + + +func _set_failure_handler() -> void: + if not GdUnitSignals.instance().gdunit_set_test_failed.is_connected(_failure_received): + @warning_ignore("return_value_discarded") + GdUnitSignals.instance().gdunit_set_test_failed.connect(_failure_received) + + +func _remove_failure_handler() -> void: + if GdUnitSignals.instance().gdunit_set_test_failed.is_connected(_failure_received): + GdUnitSignals.instance().gdunit_set_test_failed.disconnect(_failure_received) + + +func _failure_received(is_failed: bool) -> void: + # is already failed? + if _failed: + return + _failed = is_failed + Engine.set_meta("GD_TEST_FAILURE", is_failed) + + +func stop_timer() -> void: + # finish outstanding timeouts + if is_instance_valid(_timer): + _timer.stop() + _timer.call_deferred("free") + _timer = null + + +func expect_to_interupt() -> void: + _expect_to_interupt = true + + +func is_interupted() -> bool: + return _interupted + + +func is_expect_interupted() -> bool: + return _expect_to_interupt + + +func is_terminated() -> bool: + return _terminated + + +func is_parameterized() -> bool: + return _parameter_resolver != null + + +func is_skipped() -> bool: + return _attribute.is_skipped + + +func skip_info() -> String: + return _attribute.skip_reason + + +func id() -> GdUnitGUID: + return _test_case.guid + + +func test_name() -> String: + return _test_case.test_name + + +func line_number() -> int: + return _test_case.line_number + + +func iterations() -> int: + return _attribute.fuzzer_iterations + + +func seed_value() -> int: + return _attribute.test_seed + + +func is_fuzzed() -> bool: + return not _attribute.fuzzers.is_empty() + + +func fuzzer_arguments() -> Array[GdFunctionArgument]: + return _attribute.fuzzers + + +func script_path() -> String: + return _test_case.source_file + + +func ResourcePath() -> String: + return _test_case.source_file + + +func generate_seed() -> void: + if _attribute.test_seed != -1: + seed(_attribute.test_seed) + + +func do_skip(skipped: bool, reason: String="") -> void: + _attribute.is_skipped = skipped + _attribute.skip_reason = reason + + +func _to_string() -> String: + return "%s :%d (%dms)" % [get_name(), _test_case.line_number, _attribute.timeout] diff --git a/addons/gdUnit4/src/core/_TestCase.gd.uid b/addons/gdUnit4/src/core/_TestCase.gd.uid new file mode 100644 index 0000000..808c2eb --- /dev/null +++ b/addons/gdUnit4/src/core/_TestCase.gd.uid @@ -0,0 +1 @@ +uid://bmajjbdju6441 diff --git a/addons/gdUnit4/src/core/assets/touch-button.png b/addons/gdUnit4/src/core/assets/touch-button.png new file mode 100644 index 0000000..23f46ef Binary files /dev/null and b/addons/gdUnit4/src/core/assets/touch-button.png differ diff --git a/addons/gdUnit4/src/core/assets/touch-button.png.import b/addons/gdUnit4/src/core/assets/touch-button.png.import new file mode 100644 index 0000000..629c422 --- /dev/null +++ b/addons/gdUnit4/src/core/assets/touch-button.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bup04jpdtegir" +path="res://.godot/imported/touch-button.png-2fff40c8520d8e97a57db1b2b043f641.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gdUnit4/src/core/assets/touch-button.png" +dest_files=["res://.godot/imported/touch-button.png-2fff40c8520d8e97a57db1b2b043f641.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd new file mode 100644 index 0000000..cf7a2b9 --- /dev/null +++ b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd @@ -0,0 +1,76 @@ +class_name TestCaseAttribute +extends Resource +## Holds configuration and metadata for individual test cases.[br] +## [br] +## This class defines test behaviors and properties such as:[br] +## - Test timeouts[br] +## - Skip conditions[br] +## - Fuzzing parameters[br] +## - Random seed values[br] + + +## When set, no specific timeout value is configured and test will use the [code]test_timeout[/code][br] +## value from [GdUnitSettings]. +const DEFAULT_TIMEOUT := -1 + + +## The maximum time in milliseconds for test completion.[br] +## The test fails if execution exceeds this duration.[br] +## [br] +## When set to [constant DEFAULT_TIMEOUT], uses the value from [method GdUnitSettings.test_timeout]. +var timeout: int = DEFAULT_TIMEOUT: + set(value): + timeout = value + get: + if timeout == DEFAULT_TIMEOUT: + # get the default timeout from the settings + timeout = GdUnitSettings.test_timeout() + return timeout + + +## The seed used for random number generation in the test.[br] +## Ensures reproducible results for randomized test scenarios.[br] +## A value of -1 indicates no specific seed is set. +var test_seed: int = -1 + + +## Controls whether this test should be skipped during execution.[br] +## Useful for temporarily disabling tests without removing them. +var is_skipped := false + + +## Documents why the test is being skipped.[br] +## [br] +## Should explain the reason for skipping and ideally include:[br] +## - Why the test was disabled[br] +## - Under what conditions it should be re-enabled[br] +## - Any related issues or tickets +var skip_reason := "Unknown" + + +## Number of iterations to run when using fuzzers.[br] +## [br] +## Fuzzers generate random test data to help find edge cases.[br] +## Higher values provide better coverage but increase test duration. +var fuzzer_iterations: int = Fuzzer.ITERATION_DEFAULT_COUNT + + +## Array of fuzzer configurations for test parameters.[br] +## [br] +## Each [GdFunctionArgument] defines how random test data[br] +## should be generated for a particular parameter. +var fuzzers: Array[GdFunctionArgument] = [] + + +# There is a bug in `duplicate` see https://github.com/godotengine/godot/issues/98644 +# we need in addition to overwrite default values with the source values +@warning_ignore("native_method_override") +func clone() -> Resource: + var copy: TestCaseAttribute = TestCaseAttribute.new() + copy.timeout = timeout + copy.test_seed = test_seed + copy.is_skipped = is_skipped + copy.skip_reason = skip_reason + copy.fuzzer_iterations = fuzzer_iterations + copy.fuzzers = fuzzers.duplicate() + return copy diff --git a/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid new file mode 100644 index 0000000..ca45748 --- /dev/null +++ b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid @@ -0,0 +1 @@ +uid://dsy273sa1yj2g diff --git a/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd new file mode 100644 index 0000000..f5c5ffb --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd @@ -0,0 +1,64 @@ +@abstract class_name GdUnitBaseCommand +extends Node + + +var id: String +var icon: Texture2D +var shortcut: Shortcut = null +var shortcut_type: GdUnitShortcut.ShortCut + + +func _init(p_id: String, p_shortcut: GdUnitShortcut.ShortCut = GdUnitShortcut.ShortCut.NONE) -> void: + id = p_id + shortcut_type = p_shortcut + _set_shortcut() + + +func _shortcut_input(event: InputEvent) -> void: + if is_running(): + return + + if shortcut and shortcut.matches_event(event): + execute() + get_viewport().set_input_as_handled() + + +func update_shortcut() -> void: + _set_shortcut() + + +func _set_shortcut() -> void: + if shortcut_type == GdUnitShortcut.ShortCut.NONE: + return + + var property_name := GdUnitShortcut.as_property(shortcut_type) + var property := GdUnitSettings.get_property(property_name) + var keys := GdUnitShortcut.default_keys(shortcut_type) + if property != null: + keys = property.value() + var inputEvent := _create_shortcut_input_even(keys) + + shortcut = Shortcut.new() + shortcut.set_events([inputEvent]) + + +func _create_shortcut_input_even(key_codes: PackedInt32Array) -> InputEventKey: + var inputEvent := InputEventKey.new() + inputEvent.pressed = true + for key_code in key_codes: + match key_code: + KEY_ALT: + inputEvent.alt_pressed = true + KEY_SHIFT: + inputEvent.shift_pressed = true + KEY_CTRL: + inputEvent.ctrl_pressed = true + _: + inputEvent.keycode = key_code as Key + inputEvent.physical_keycode = key_code as Key + return inputEvent + + +@abstract func is_running() -> bool + +@abstract func execute(...parameters: Array) -> void diff --git a/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid new file mode 100644 index 0000000..5f5e3c2 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid @@ -0,0 +1 @@ +uid://dlmrce8c18asg diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd new file mode 100644 index 0000000..6a173c5 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd @@ -0,0 +1,42 @@ +@abstract class_name GdUnitCommandFileSystem +extends GdUnitBaseCommand + + +var _test_session_command: GdUnitCommandTestSession + +func _init(p_id: String, p_shortcut: GdUnitShortcut.ShortCut, test_session_command: GdUnitCommandTestSession) -> void: + super(p_id, p_shortcut) + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute_tests(paths: PackedStringArray, with_debug: bool) -> void: + var suite_scaner := GdUnitTestSuiteScanner.new() + var scripts: Array[Script] + + for resource_path in paths: + # directories and test-suites are valid to enable the menu + if DirAccess.dir_exists_absolute(resource_path): + scripts.append_array(suite_scaner.scan_directory(resource_path)) + continue + + var file_type := resource_path.get_extension() + if file_type == "gd" or file_type == "cs": + var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(resource_path) + + if GdUnitTestSuiteScanner.is_test_suite(script): + scripts.append(script) + + GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new()) + var tests_to_execute: Array[GdUnitTestCase] = [] + for script in scripts: + GdUnitTestDiscoverer.discover_tests(script, func(test_case: GdUnitTestCase) -> void: + tests_to_execute.append(test_case) + GdUnitTestDiscoverSink.discover(test_case) + ) + GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0)) + GdUnitTestDiscoverer.console_log_discover_results(tests_to_execute) + _test_session_command.execute(tests_to_execute, with_debug) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid new file mode 100644 index 0000000..36fd7ab --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid @@ -0,0 +1 @@ +uid://cm5tbyh1745qy diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd new file mode 100644 index 0000000..c4c553c --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd @@ -0,0 +1,17 @@ +class_name GdUnitCommandFileSystemDebugTests +extends GdUnitCommandFileSystem + + +const ID := "Debug FileSystem Tests" + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RUN_TESTSUITE_DEBUG, test_session_command) + icon = GdUnitUiTools.get_icon("PlayStart") + + +func execute(...parameters: Array) -> void: + if parameters.is_empty(): + return + var selected_paths: PackedStringArray = parameters[0] + execute_tests(selected_paths, true) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid new file mode 100644 index 0000000..7f93e4e --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid @@ -0,0 +1 @@ +uid://4g8q6xdpejtg diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd new file mode 100644 index 0000000..d17413f --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd @@ -0,0 +1,15 @@ +class_name GdUnitCommandFileSystemRunTests +extends GdUnitCommandFileSystem + + +const ID := "Run FileSystem Tests" + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RUN_TESTSUITE, test_session_command) + icon = GdUnitUiTools.get_icon("Play") + + +func execute(...parameters: Array) -> void: + var selected_paths: PackedStringArray = parameters[0] + execute_tests(selected_paths, false) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid new file mode 100644 index 0000000..2ac5aa0 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid @@ -0,0 +1 @@ +uid://bfs4tlpf2nik diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd new file mode 100644 index 0000000..78c38ce --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd @@ -0,0 +1,126 @@ +class_name GdUnitCommandHandler +extends Object + + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +var _commnand_mappings: Dictionary[String, GdUnitBaseCommand]= {} +var test_session_command := GdUnitCommandTestSession.new() + +static func instance() -> GdUnitCommandHandler: + return GdUnitSingleton.instance("GdUnitCommandHandler", func() -> GdUnitCommandHandler: return GdUnitCommandHandler.new()) + + +@warning_ignore("return_value_discarded") +func _init() -> void: + GdUnitSignals.instance().gdunit_event.connect(_on_event) + GdUnitSignals.instance().gdunit_client_disconnected.connect(_on_client_disconnected) + GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed) + + _register_command(test_session_command) + _register_command(GdUnitCommandStopTestSession.new(test_session_command)) + _register_command(GdUnitCommandInspectorRunTests.new(test_session_command)) + _register_command(GdUnitCommandInspectorDebugTests.new(test_session_command)) + _register_command(GdUnitCommandInspectorRerunTestsUntilFailure.new(test_session_command)) + _register_command(GdUnitCommandInspectorTreeCollapse.new()) + _register_command(GdUnitCommandInspectorTreeExpand.new()) + _register_command(GdUnitCommandScriptEditorRunTests.new(test_session_command)) + _register_command(GdUnitCommandScriptEditorDebugTests.new(test_session_command)) + _register_command(GdUnitCommandScriptEditorCreateTest.new()) + _register_command(GdUnitCommandFileSystemRunTests.new(test_session_command)) + _register_command(GdUnitCommandFileSystemDebugTests.new(test_session_command)) + _register_command(GdUnitCommandRunTestsOverall.new(test_session_command)) + + # schedule discover tests if enabled and running inside the editor + if Engine.is_editor_hint() and GdUnitSettings.is_test_discover_enabled(): + var timer :SceneTreeTimer = (Engine.get_main_loop() as SceneTree).create_timer(5) + @warning_ignore("return_value_discarded") + timer.timeout.connect(cmd_discover_tests) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + for command: GdUnitBaseCommand in _commnand_mappings.values(): + if Engine.is_editor_hint(): + EditorInterface.get_command_palette().remove_command("GdUnit4/"+command.id) + command.free() + _commnand_mappings.clear() + + +func _do_process() -> void: + # Do stop test execution when the user has stoped the test runner manually by hit the Godot editor stop button + if test_session_command._is_debug and test_session_command.is_running() and not EditorInterface.is_playing_scene(): + if GdUnitSettings.is_verbose_assert_warnings(): + print_debug("Test Runner scene was stopped manually, force stopping the current test run!") + command_execute(GdUnitCommandStopTestSession.ID) + + +func command_icon(command_id: String) -> Texture2D: + if not _commnand_mappings.has(command_id): + push_error("GdUnitCommandHandler:command_icon(): No command id '%s' is registered." % command_id) + print_stack() + return + return _commnand_mappings[command_id].icon + + +func command_shortcut(command_id: String) -> Shortcut: + if not _commnand_mappings.has(command_id): + push_error("GdUnitCommandHandler:command_shortcut(): No command id '%s' is registered." % command_id) + print_stack() + return + return _commnand_mappings[command_id].shortcut + + +func command_execute(...parameters: Array) -> void: + if parameters.is_empty(): + push_error("Invalid arguments used on CommandHandler:execute()! Expecting []") + print_stack() + return + + var command_id: String = parameters.pop_front() + if not _commnand_mappings.has(command_id): + push_error("GdUnitCommandHandler:command_execute(): No command id '%s' is registered." % command_id) + print_stack() + return + await _commnand_mappings[command_id].callv("execute", parameters) + + +func _register_command(command: GdUnitBaseCommand) -> void: + # first verify the command is not already registerd + if _commnand_mappings.has(command.id): + push_error("GdUnitCommandHandler:_register_command(): Command with id '%s' is already registerd!" % command.id) + return + + _commnand_mappings[command.id] = command + if Engine.is_editor_hint(): + EditorInterface.get_base_control().add_child(command) + EditorInterface.get_command_palette().add_command(command.id, "GdUnit4/"+command.id, command.execute, command.shortcut.get_as_text() if command.shortcut else "None") + + +func cmd_discover_tests() -> void: + await GdUnitTestDiscoverer.run() + + +################################################################################ +# signals handles +################################################################################ +func _on_event(event: GdUnitEvent) -> void: + if event.type() == GdUnitEvent.SESSION_CLOSE: + command_execute(GdUnitCommandStopTestSession.ID) + + +func _on_settings_changed(property: GdUnitProperty) -> void: + for command: GdUnitBaseCommand in _commnand_mappings.values(): + command.update_shortcut() + + if property.name() == GdUnitSettings.TEST_DISCOVER_ENABLED: + var timer :SceneTreeTimer = (Engine.get_main_loop() as SceneTree).create_timer(3) + @warning_ignore("return_value_discarded") + timer.timeout.connect(cmd_discover_tests) + + +################################################################################ +# Network stuff +################################################################################ +func _on_client_disconnected(_client_id: int) -> void: + command_execute(GdUnitCommandStopTestSession.ID) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid new file mode 100644 index 0000000..b6c1534 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid @@ -0,0 +1 @@ +uid://bkkdtt7dds0ln diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd new file mode 100644 index 0000000..0c9198f --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd @@ -0,0 +1,27 @@ +class_name GdUnitCommandInspectorDebugTests +extends GdUnitBaseCommand + +const GdUnitInspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd") +const ID := "Debug Inspector Tests" + + +var _test_session_command: GdUnitCommandTestSession + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RERUN_TESTS_DEBUG) + icon = GdUnitUiTools.get_icon("PlayStart") + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute(..._parameters: Array) -> void: + var base_control := EditorInterface.get_base_control() + var inspector: GdUnitInspectorTreeMainPanel = base_control.get_meta("GdUnit4Inspector") + var selected_item := inspector._tree.get_selected() + var tests_to_execute := inspector.collect_test_cases(selected_item) + + _test_session_command.execute(tests_to_execute, true) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid new file mode 100644 index 0000000..e213e82 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid @@ -0,0 +1 @@ +uid://comsfx3w735pe diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd new file mode 100644 index 0000000..9a11c86 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd @@ -0,0 +1,57 @@ +class_name GdUnitCommandInspectorRerunTestsUntilFailure +extends GdUnitBaseCommand + + +signal session_closed() + + +const GdUnitInspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd") +const ID := "Rerun Inspector Tests Until Failure" + + +var _test_session_command: GdUnitCommandTestSession +var _current_execution_count := 0 + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RERUN_TESTS_UNTIL_FAILURE) + icon = GdUnitUiTools.get_icon("Play") + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute(..._parameters: Array) -> void: + var base_control := EditorInterface.get_base_control() + var inspector: GdUnitInspectorTreeMainPanel = base_control.get_meta("GdUnit4Inspector") + var selected_item := inspector._tree.get_selected() + var tests_to_execute := inspector.collect_test_cases(selected_item) + var rerun_until_failure_count := GdUnitSettings.get_rerun_max_retries() + var saved_settings: bool = ProjectSettings.get_setting(GdUnitSettings.TEST_FLAKY_CHECK) + ProjectSettings.set_setting(GdUnitSettings.TEST_FLAKY_CHECK, false) + + GdUnitSignals.instance().gdunit_event.connect(_on_test_event) + _current_execution_count = 0 + + _test_session_command._is_fail_fast = true + while _current_execution_count < rerun_until_failure_count: + _test_session_command.execute(tests_to_execute, true) + await session_closed + _test_session_command._is_fail_fast = false + + ProjectSettings.set_setting(GdUnitSettings.TEST_FLAKY_CHECK, saved_settings) + GdUnitSignals.instance().gdunit_event.disconnect(_on_test_event) + + +func _on_test_event(event: GdUnitEvent) -> void: + if event.type() == GdUnitEvent.SESSION_START: + _current_execution_count += 1 + GdUnitSignals.instance().gdunit_message.emit("[color=RED]Execution Mode: ReRun until failure! (iteration %d)[/color]" % _current_execution_count) + if event.type() == GdUnitEvent.SESSION_CLOSE: + session_closed.emit() + if event.type() == GdUnitEvent.TESTCASE_AFTER: + if not event.is_success(): + GdUnitSignals.instance().gdunit_message.emit(" [color=RED](iteration: %d)[/color]" % _current_execution_count) + _current_execution_count = 9999 diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid new file mode 100644 index 0000000..07a1768 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid @@ -0,0 +1 @@ +uid://cbuyubtrrudlg diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd new file mode 100644 index 0000000..436f171 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd @@ -0,0 +1,26 @@ +class_name GdUnitCommandInspectorRunTests +extends GdUnitBaseCommand + +const GdUnitInspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd") +const ID := "Run Inspector Tests" + + +var _test_session_command: GdUnitCommandTestSession + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RERUN_TESTS) + icon = GdUnitUiTools.get_icon("Play") + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute(..._parameters: Array) -> void: + var base_control := EditorInterface.get_base_control() + var inspector: GdUnitInspectorTreeMainPanel = base_control.get_meta("GdUnit4Inspector") + var selected_item := inspector._tree.get_selected() + var tests_to_execute := inspector.collect_test_cases(selected_item) + _test_session_command.execute(tests_to_execute, false) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid new file mode 100644 index 0000000..d484bec --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid @@ -0,0 +1 @@ +uid://dd33tu6rn7i2w diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd new file mode 100644 index 0000000..f155fb3 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd @@ -0,0 +1,26 @@ +class_name GdUnitCommandInspectorTreeCollapse +extends GdUnitBaseCommand + +const GdUnitInspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd") +const ID := "Inspector Tree Collapse" + + +func _init() -> void: + super(ID, GdUnitShortcut.ShortCut.NONE) + icon = GdUnitUiTools.get_icon("CollapseTree") + + +func is_running() -> bool: + return false + + +func execute(..._parameters: Array) -> void: + var inspector: GdUnitInspectorTreeMainPanel = EditorInterface.get_base_control().get_meta("GdUnit4Inspector") + var selected_item := inspector._tree.get_selected() + if selected_item == null: + selected_item = inspector._tree.get_root() + else: + selected_item = selected_item.get_parent() + + inspector.do_collapse_all(false, selected_item) + inspector.do_collapse_all(true, selected_item) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid new file mode 100644 index 0000000..e44f78d --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid @@ -0,0 +1 @@ +uid://dyhv7pgc4ib68 diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd new file mode 100644 index 0000000..b5754fe --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd @@ -0,0 +1,25 @@ +class_name GdUnitCommandInspectorTreeExpand +extends GdUnitBaseCommand + +const GdUnitInspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd") +const ID := "Inspector Tree Expand" + + +func _init() -> void: + super(ID, GdUnitShortcut.ShortCut.NONE) + icon = GdUnitUiTools.get_icon("ExpandTree") + + +func is_running() -> bool: + return false + + +func execute(..._parameters: Array) -> void: + var inspector: GdUnitInspectorTreeMainPanel = EditorInterface.get_base_control().get_meta("GdUnit4Inspector") + var selected_item := inspector._tree.get_selected() + if selected_item == null: + selected_item = inspector._tree.get_root() + else: + selected_item = selected_item.get_parent() + + inspector.do_collapse_all(false, selected_item) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid new file mode 100644 index 0000000..e601164 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid @@ -0,0 +1 @@ +uid://b1phrc5j20yly diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd new file mode 100644 index 0000000..daf534b --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd @@ -0,0 +1,22 @@ +class_name GdUnitCommandRunTestsOverall +extends GdUnitBaseCommand + +const ID := "Run Tests Overall" + + +var _test_session_command: GdUnitCommandTestSession + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RUN_TESTS_OVERALL) + icon = GdUnitUiTools.get_run_overall_icon() + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute(..._parameters: Array) -> void: + var tests_to_execute := await GdUnitTestDiscoverer.run() + _test_session_command.execute(tests_to_execute, true) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid new file mode 100644 index 0000000..ce58e68 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid @@ -0,0 +1 @@ +uid://bwqmfhv4lsepr diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd new file mode 100644 index 0000000..5504fa7 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd @@ -0,0 +1,56 @@ +@abstract class_name GdUnitCommandScriptEditor +extends GdUnitBaseCommand + +var _test_session_command: GdUnitCommandTestSession + +func _init(p_id: String, p_shortcut: GdUnitShortcut.ShortCut, test_session_command: GdUnitCommandTestSession) -> void: + super(p_id, p_shortcut) + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute_tests(with_debug: bool) -> void: + var selected_tests := PackedStringArray() + if _is_active_script_editor(): + var cursor_line := _active_base_editor().get_caret_line() + #run test case? + var regex := RegEx.new() + @warning_ignore("return_value_discarded") + regex.compile("(^func[ ,\t])(test_[a-zA-Z0-9_]*)") + var result := regex.search(_active_base_editor().get_line(cursor_line)) + if result: + var func_name := result.get_string(2).strip_edges() + if func_name.begins_with("test_"): + selected_tests.append(func_name) + + var tests_to_execute := _collect_tests(_active_script(), selected_tests) + _test_session_command.execute(tests_to_execute, with_debug) + + +func _collect_tests(script: Script, tests: PackedStringArray) -> Array[GdUnitTestCase]: + # Update test discovery + var tests_to_execute: Array[GdUnitTestCase] = [] + GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new()) + GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void: + if tests.is_empty() or tests.has(test.test_name): + tests_to_execute.append(test) + GdUnitTestDiscoverSink.discover(test) + ) + GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0)) + GdUnitTestDiscoverer.console_log_discover_results(tests_to_execute) + return tests_to_execute + + +func _is_active_script_editor() -> bool: + return EditorInterface.get_script_editor().get_current_editor() != null + + +func _active_base_editor() -> TextEdit: + return EditorInterface.get_script_editor().get_current_editor().get_base_editor() + + +func _active_script() -> Script: + return EditorInterface.get_script_editor().get_current_script() diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid new file mode 100644 index 0000000..3b735ed --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid @@ -0,0 +1 @@ +uid://c2yv18b62p8im diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd new file mode 100644 index 0000000..d2deaed --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd @@ -0,0 +1,41 @@ +class_name GdUnitCommandScriptEditorCreateTest +extends GdUnitBaseCommand + + +const ID := "Create Test" + + +func _init() -> void: + super(ID, GdUnitShortcut.ShortCut.CREATE_TEST) + icon = GdUnitUiTools.get_icon("New") + + +func is_running() -> bool: + return false + + +func execute(..._parameters: Array) -> void: + if not _is_active_script_editor(): + return + var cursor_line := _active_base_editor().get_caret_line() + var result := GdUnitTestSuiteBuilder.create(_active_script(), cursor_line) + if result.is_error(): + # show error dialog + push_error("Failed to create test case: %s" % result.error_message()) + return + var info: Dictionary = result.value() + var script_path: String = info.get("path") + var script_line: int = info.get("line") + GdUnitScriptEditorControls.edit_script(script_path, script_line) + + +func _is_active_script_editor() -> bool: + return EditorInterface.get_script_editor().get_current_editor() != null + + +func _active_base_editor() -> TextEdit: + return EditorInterface.get_script_editor().get_current_editor().get_base_editor() + + +func _active_script() -> Script: + return EditorInterface.get_script_editor().get_current_script() diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid new file mode 100644 index 0000000..1c80bf3 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid @@ -0,0 +1 @@ +uid://cj6i37veusb0t diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd new file mode 100644 index 0000000..8ab9f24 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd @@ -0,0 +1,14 @@ +class_name GdUnitCommandScriptEditorDebugTests +extends GdUnitCommandScriptEditor + + +const ID := "Debug ScriptEditor Tests" + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RUN_TESTCASE_DEBUG, test_session_command) + icon = GdUnitUiTools.get_icon("PlayStart") + + +func execute(..._parameters: Array) -> void: + execute_tests(true) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid new file mode 100644 index 0000000..9db1ca3 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid @@ -0,0 +1 @@ +uid://bmxopy5k03qyr diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd new file mode 100644 index 0000000..a18851a --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd @@ -0,0 +1,14 @@ +class_name GdUnitCommandScriptEditorRunTests +extends GdUnitCommandScriptEditor + + +const ID := "Run ScriptEditor Tests" + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.RUN_TESTCASE, test_session_command) + icon = GdUnitUiTools.get_icon("Play") + + +func execute(..._parameters: Array) -> void: + execute_tests(false) diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid new file mode 100644 index 0000000..cf9eda5 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid @@ -0,0 +1 @@ +uid://b6hf3dvxf47y1 diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd new file mode 100644 index 0000000..50a6df7 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd @@ -0,0 +1,21 @@ +class_name GdUnitCommandStopTestSession +extends GdUnitBaseCommand + +const ID := "Stop Test Session" + + +var _test_session_command: GdUnitCommandTestSession + + +func _init(test_session_command: GdUnitCommandTestSession) -> void: + super(ID, GdUnitShortcut.ShortCut.STOP_TEST_RUN) + icon = GdUnitUiTools.get_icon("Stop") + _test_session_command = test_session_command + + +func is_running() -> bool: + return _test_session_command.is_running() + + +func execute(..._parameters: Array) -> void: + await _test_session_command.stop() diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid new file mode 100644 index 0000000..a3ab713 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid @@ -0,0 +1 @@ +uid://cshn8rl0d6vd3 diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd new file mode 100644 index 0000000..a901035 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd @@ -0,0 +1,130 @@ +class_name GdUnitCommandTestSession +extends GdUnitBaseCommand + + +const ID := "Start Test Session" + + +var _current_runner_process_id: int +var _is_running: bool +var _is_debug: bool +var _is_fail_fast: bool + + +func _init() -> void: + super(ID, GdUnitShortcut.ShortCut.NONE) + _is_running = false + _is_fail_fast = false + # Start with an invalid PID so we never treat the default `0` as a real runner process. + _current_runner_process_id = -1 + + +func is_running() -> bool: + return _is_running + + +func stop() -> void: + if not is_running(): + return + _is_running = false + + if _is_debug: + force_pause_scene() + + GdUnitSignals.instance().gdunit_test_session_terminate.emit() + # Give the API time to commit terminate to the client + await get_tree().create_timer(.5).timeout + + if _is_debug: + # In debug mode the runner is a played scene, not a spawned process, so there is no PID to kill. + if EditorInterface.is_playing_scene(): + EditorInterface.stop_playing_scene() + elif _is_valid_runner_process(_current_runner_process_id): + var result := OS.kill(_current_runner_process_id) + if result != OK: + push_error("Failed to stop GdUnit Test Runner (pid: %d). OS.kill() returned: %s" % [_current_runner_process_id, result]) + _current_runner_process_id = -1 + # We need finaly to send the test session close event because the current run is hard aborted. + GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionClose.new()) + + +static func _is_valid_runner_process(pid: int) -> bool: + return pid > 0 and OS.is_process_running(pid) + + +## Forces the running scene to unpause when the debugger hits a breakpoint.[br] +## [br] +## When the Godot debugger stops at a breakpoint during test execution, it blocks[br] +## the main thread. This prevents signals and TCP communications from being processed,[br] +## which can cause GdUnit4 tests to hang or fail to communicate properly with the[br] +## test runner. This function programmatically unpauses the scene to restore[br] +## main thread execution while maintaining debugger functionality. [br] +## [br] +## [b]Technical Background:[/b][br] +## - Debugger breakpoints freeze the main thread to allow inspection[br] +## - Frozen main thread blocks signal processing and network communications[br] +## - GdUnit4 requires active signal/TCP processing for test coordination[br] +## - This function finds and triggers the editor's pause button to resume execution[br] +## [br] +## [b]How It Works:[/b][br] +## 1. Locates the EditorRunBar in the Godot editor UI hierarchy[br] +## 2. Searches for the pause button by matching its icon[br] +## 3. Unpresses the button if it's currently pressed (paused state)[br] +## 4. Manually triggers the button's connected callbacks to resume execution[br] +func force_pause_scene() -> bool: + var nodes := EditorInterface.get_base_control().find_children("*", "EditorRunBar", true, false) + if nodes.size() != 1: + push_error("GdUnitCommandTestSession:force_pause_scene() Can't find Editor component 'EditorRunBar'") + return false + var editor_run_bar := nodes[0] + var containers := editor_run_bar.find_children("*", "HBoxContainer", true, false) + var pause_icon := GdUnitUiTools.get_icon("Pause") + + for container in containers: + for child in container.get_children(): + if child is Button: + var button: Button = child + if pause_icon == button.icon: + button.set_pressed(false) + + var connected_signals := button.get_signal_connection_list("pressed") + if not connected_signals.is_empty(): + for signal_ in connected_signals: + var cb: Callable = signal_["callable"] + cb.call() + return true + push_error("GdUnitCommandTestSession:force_pause_scene() Can't find Editor component 'EditorRunBar'") + return false + + +func execute(...parameters: Array) -> void: + var tests_to_execute: Array[GdUnitTestCase] = parameters[0] + _is_debug = parameters[1] + + _prepare_test_session(tests_to_execute) + if _is_debug: + EditorInterface.play_custom_scene("res://addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn") + else: + var arguments := Array() + if OS.is_stdout_verbose(): + arguments.append("--verbose") + arguments.append("--no-window") + arguments.append("--path") + arguments.append(ProjectSettings.globalize_path("res://")) + arguments.append("res://addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn") + _current_runner_process_id = OS.create_process(OS.get_executable_path(), arguments, false); + _is_running = true + + +func _prepare_test_session(tests_to_execute: Array[GdUnitTestCase]) -> void: + var server_port: int = Engine.get_meta("gdunit_server_port") + var result := GdUnitRunnerConfig.new() \ + .set_server_port(server_port) \ + .do_fail_fast(_is_fail_fast) \ + .add_test_cases(tests_to_execute) \ + .save_config() + if result.is_error(): + push_error(result.error_message()) + return + # before start we have to save all scrpt changes + GdUnitScriptEditorControls.save_all_open_script() diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid new file mode 100644 index 0000000..7d3ac3e --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid @@ -0,0 +1 @@ +uid://bf0kdterj74cw diff --git a/addons/gdUnit4/src/core/command/GdUnitShortcut.gd b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd new file mode 100644 index 0000000..33af082 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd @@ -0,0 +1,73 @@ +class_name GdUnitShortcut +extends RefCounted + + +enum ShortCut { + NONE, + RUN_TESTS_OVERALL, + RUN_TESTCASE, + RUN_TESTCASE_DEBUG, + RUN_TESTSUITE, + RUN_TESTSUITE_DEBUG, + RERUN_TESTS, + RERUN_TESTS_DEBUG, + RERUN_TESTS_UNTIL_FAILURE, + STOP_TEST_RUN, + CREATE_TEST, +} + +const DEFAULTS_MACOS := { + ShortCut.NONE : [], + ShortCut.RUN_TESTCASE : [Key.KEY_META, Key.KEY_ALT, Key.KEY_F5], + ShortCut.RUN_TESTCASE_DEBUG : [Key.KEY_META, Key.KEY_ALT, Key.KEY_F6], + ShortCut.RUN_TESTSUITE : [Key.KEY_ALT, Key.KEY_META, Key.KEY_F5], + ShortCut.RUN_TESTSUITE_DEBUG : [Key.KEY_ALT, Key.KEY_META, Key.KEY_F6], + ShortCut.RUN_TESTS_OVERALL : [Key.KEY_ALT, Key.KEY_F7], + ShortCut.STOP_TEST_RUN : [Key.KEY_ALT, Key.KEY_F8], + ShortCut.RERUN_TESTS : [Key.KEY_ALT, Key.KEY_F5], + ShortCut.RERUN_TESTS_DEBUG : [Key.KEY_ALT, Key.KEY_F6], + ShortCut.RERUN_TESTS_UNTIL_FAILURE : [Key.KEY_ALT, Key.KEY_META, Key.KEY_F5], + ShortCut.CREATE_TEST : [Key.KEY_META, Key.KEY_ALT, Key.KEY_F10], +} + +const DEFAULTS_WINDOWS := { + ShortCut.NONE : [], + ShortCut.RUN_TESTCASE : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F5], + ShortCut.RUN_TESTCASE_DEBUG : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F6], + ShortCut.RUN_TESTSUITE : [Key.KEY_ALT, Key.KEY_SHIFT, Key.KEY_F5], + ShortCut.RUN_TESTSUITE_DEBUG : [Key.KEY_ALT, Key.KEY_SHIFT, Key.KEY_F6], + ShortCut.RUN_TESTS_OVERALL : [Key.KEY_ALT, Key.KEY_F7], + ShortCut.STOP_TEST_RUN : [Key.KEY_ALT, Key.KEY_F8], + ShortCut.RERUN_TESTS : [Key.KEY_ALT, Key.KEY_F5], + ShortCut.RERUN_TESTS_DEBUG : [Key.KEY_ALT, Key.KEY_F6], + ShortCut.RERUN_TESTS_UNTIL_FAILURE : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F5], + ShortCut.CREATE_TEST : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F10], +} + + +const SETTINGS_MAPPING: Dictionary[ShortCut, String] = { + ShortCut.RUN_TESTCASE : GdUnitSettings.SHORTCUT_EDITOR_RUN_TEST, + ShortCut.RUN_TESTCASE_DEBUG : GdUnitSettings.SHORTCUT_EDITOR_RUN_TEST_DEBUG, + ShortCut.CREATE_TEST : GdUnitSettings.SHORTCUT_EDITOR_CREATE_TEST, + ShortCut.RERUN_TESTS : GdUnitSettings.SHORTCUT_INSPECTOR_RERUN_TEST, + ShortCut.RERUN_TESTS_DEBUG : GdUnitSettings.SHORTCUT_INSPECTOR_RERUN_TEST_DEBUG, + ShortCut.RERUN_TESTS_UNTIL_FAILURE : GdUnitSettings.SHORTCUT_INSPECTOR_RERUN_TEST_UNTIL_FAILURE, + ShortCut.STOP_TEST_RUN : GdUnitSettings.SHORTCUT_INSPECTOR_RUN_TEST_STOP, + ShortCut.RUN_TESTS_OVERALL : GdUnitSettings.SHORTCUT_INSPECTOR_RUN_TEST_OVERALL, + ShortCut.RUN_TESTSUITE : GdUnitSettings.SHORTCUT_FILESYSTEM_RUN_TEST, + ShortCut.RUN_TESTSUITE_DEBUG : GdUnitSettings.SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG +} + + +static func as_property(sortcut: ShortCut) -> String: + return SETTINGS_MAPPING[sortcut] + + +static func default_keys(shortcut: ShortCut) -> PackedInt32Array: + match OS.get_name().to_lower(): + 'windows': + return DEFAULTS_WINDOWS[shortcut] + 'macos': + return DEFAULTS_MACOS[shortcut] + _: + return DEFAULTS_WINDOWS[shortcut] diff --git a/addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid new file mode 100644 index 0000000..2592781 --- /dev/null +++ b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid @@ -0,0 +1 @@ +uid://blxhr6jed3vll diff --git a/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd new file mode 100644 index 0000000..00332f9 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd @@ -0,0 +1,46 @@ +## A class representing a globally unique identifier for GdUnit test elements. +## Uses random values to generate unique identifiers that can be used +## to track and reference test cases and suites across the test framework. +class_name GdUnitGUID +extends RefCounted + + +## The internal string representation of the GUID. +## Generated using Godot's ResourceUID system when no existing GUID is provided. +var _guid: String + + +## Creates a new GUID instance. +## If no GUID is provided, generates a new one using Godot's ResourceUID system. +func _init(from_guid: String = "") -> void: + if from_guid.is_empty(): + _guid = _generate_guid() + else: + _guid = from_guid + + +## Compares this GUID with another for equality. +## Returns true if both GUIDs represent the same unique identifier. +func equals(other: GdUnitGUID) -> bool: + return other._guid == _guid + + +## Generates a custom GUID using random bytes.[br] +## The format uses 16 random bytes encoded to hex and formatted with hyphens. +static func _generate_guid() -> String: + # Pre-allocate array with exact size needed + var bytes := PackedByteArray() + bytes.resize(16) + + # Fill with random bytes + for i in range(16): + bytes[i] = randi() % 256 + + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + + return bytes.hex_encode().insert(8, "-").insert(16, "-").insert(24, "-") + + +func _to_string() -> String: + return _guid diff --git a/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid new file mode 100644 index 0000000..b845c7c --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid @@ -0,0 +1 @@ +uid://nsmk0bxei4wb diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd new file mode 100644 index 0000000..66ed742 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd @@ -0,0 +1,134 @@ +## GdUnitTestCase +## A class representing a single test case in GdUnit4. +## This class is used as a data container to hold all relevant information about a test case, +## including its location, dependencies, and metadata for test discovery and execution. + +class_name GdUnitTestCase +extends RefCounted + +## A unique identifier for the test case. Used to track and reference specific test instances. +var guid := GdUnitGUID.new() + +## The resource path to the test suite +var suite_resource_path: String + +## The name of the test method/function. Should start with "test_" prefix. +var test_name: String + +## The class name of the test suite containing this test case. +var suite_name: String + +## The fully qualified name of the test case following C# namespace pattern: +## Constructed from the folder path (where folders are dot-separated), the test suite name, and the test case name. +## All parts are joined by dots: {folder1.folder2.folder3}.{suite_name}.{test_name} +var fully_qualified_name: String + +var display_name: String + +## Index tracking test attributes for ordered execution. Default is 0. +## Higher values indicate later execution in the test sequence. +var attribute_index: int + +## Flag indicating if this test requires the Godot runtime environment. +## Tests requiring runtime cannot be executed in isolation. +var require_godot_runtime: bool = true + +## The path to the source file containing this test case. +## Used for test discovery and execution. +var source_file: String + +## Optional holds the assembly location for C# tests +var assembly_location: String = "" + +## The line number where the test case is defined in the source file. +## Used for navigation and error reporting. +var line_number: int = -1 + +## Additional metadata about the test case, such as: +## - tags: Array[String] - Test categories/tags for filtering +## - timeout: int - Maximum execution time in milliseconds +## - skip: bool - Whether the test should be skipped +## - dependencies: Array[String] - Required test dependencies +var metadata: Dictionary = {} + + +static func from_dict(dict: Dictionary) -> GdUnitTestCase: + var test := GdUnitTestCase.new() + test.guid = GdUnitGUID.new(str(dict["guid"])) + test.suite_resource_path = dict["suite_resource_path"] if dict.has("suite_resource_path") else dict["source_file"] + test.suite_name = dict["managed_type"] + test.test_name = dict["test_name"] + test.display_name = dict["simple_name"] + test.fully_qualified_name = dict["fully_qualified_name"] + test.attribute_index = dict["attribute_index"] + test.source_file = dict["source_file"] + test.line_number = dict["line_number"] + test.require_godot_runtime = dict["require_godot_runtime"] + test.assembly_location = dict["assembly_location"] + return test + + +static func to_dict(test: GdUnitTestCase) -> Dictionary: + return { + "guid": test.guid._guid, + "suite_resource_path": test.suite_resource_path, + "managed_type": test.suite_name, + "test_name" : test.test_name, + "simple_name" : test.display_name, + "fully_qualified_name" : test.fully_qualified_name, + "attribute_index" : test.attribute_index, + "source_file" : test.source_file, + "line_number" : test.line_number, + "require_godot_runtime" : test.require_godot_runtime, + "assembly_location" : test.assembly_location + } + + +static func from( + _suite_resource_path: String, + _source_file: String, + _line_number: int, + _test_name: String, + _attribute_index := -1, + _test_parameters := "") -> GdUnitTestCase: + + if(_source_file == null or _source_file.is_empty()): + prints(_test_name) + + assert(_test_name != null and not _test_name.is_empty(), "Precondition: The parameter 'test_name' is not set") + assert(_source_file != null and not _source_file.is_empty(), "Precondition: The parameter 'source_file' is not set") + + var test := GdUnitTestCase.new() + test.suite_resource_path = _suite_resource_path + test.test_name = _test_name + test.source_file = _source_file + test.line_number = _line_number + test.attribute_index = _attribute_index + test._build_suite_name() + test._build_display_name(_test_parameters) + test._build_fully_qualified_name(_suite_resource_path) + return test + + +func _build_suite_name() -> void: + suite_name = source_file.get_file().get_basename() + assert(suite_name != null and not suite_name.is_empty(), "Precondition: The parameter 'suite_name' can't be resolved") + + +func _build_display_name(_test_parameters: String) -> void: + if attribute_index == -1: + display_name = test_name + else: + display_name = "%s:%d (%s)" % [test_name, attribute_index, _test_parameters.trim_prefix("[").trim_suffix("]").replace('"', "'")] + + +func _build_fully_qualified_name(_resource_path: String) -> void: + var name_space := _resource_path.trim_prefix("res://").trim_suffix(".gd").trim_suffix(".cs").replace("/", ".") + + if attribute_index == -1: + fully_qualified_name = "%s.%s" % [name_space, test_name] + else: + fully_qualified_name = "%s.%s.%s" % [name_space, test_name, display_name] + assert( + fully_qualified_name != null and not fully_qualified_name.is_empty(), + "Precondition: The parameter 'fully_qualified_name' can't be resolved") diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid new file mode 100644 index 0000000..0c0fee7 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid @@ -0,0 +1 @@ +uid://deqarl8y0rv7n diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd new file mode 100644 index 0000000..6af8255 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd @@ -0,0 +1,323 @@ +## Guards and tracks test case changes during test discovery and file modifications.[br] +## [br] +## This guard maintains a cache of discovered tests to track changes between test runs and during[br] +## file modifications. It is optimized for performance using simple but effective test identity checks.[br] +## [br] +## Test Change Detection:[br] +## - Moved tests: The test implementation remains at a different line number[br] +## - Renamed tests: The test line position remains but the test name changed[br] +## - Deleted tests: A previously discovered test was removed[br] +## - Added tests: A new test was discovered[br] +## [br] +## Cache Management:[br] +## - Maintains test identity through unique GdUnitTestCase GUIDs[br] +## - Maps source files to their discovered test cases[br] +## - Tracks only essential metadata (line numbers, names) to minimize memory use[br] +## [br] +## Change Detection Strategy:[br] +## The guard uses a lightweight approach by comparing only line numbers and test names.[br] +## This avoids expensive operations like test content parsing or similarity checks.[br] +## [br] +## Event Handling:[br] +## - Emits events on test changes through GdUnitSignals[br] +## - Synchronizes cache with test discovery events[br] +## - Notifies UI about test changes[br] +## [br] +## Example usage:[br] +## [codeblock] +## # Create guard for tracking test changes +## var guard := GdUnitTestDiscoverGuard.new() +## +## # Connect to test discovery events +## GdUnitSignals.instance().gdunit_test_discovered.connect(guard.sync_test_added) +## +## # Discover tests and track changes +## await guard.discover(test_script) +## [/codeblock] +class_name GdUnitTestDiscoverGuard +extends Object + + + +static func instance() -> GdUnitTestDiscoverGuard: + return GdUnitSingleton.instance("GdUnitTestDiscoverGuard", func() -> GdUnitTestDiscoverGuard: + return GdUnitTestDiscoverGuard.new() + ) + + +## Maps source files to their discovered test cases.[br] +## [br] +## Key: Test suite source file path[br] +## Value: Array of [class GdUnitTestCase] instances +var _discover_cache := {} + + +## Tracks discovered test changes for debug purposes.[br] +## [br] +## Available in debug mode only. Contains dictionaries:[br] +## - changed_tests: Tests that were moved or renamed[br] +## - deleted_tests: Tests that were removed[br] +## - added_tests: New tests that were discovered +var _discovered_changes := {} + + +## Controls test change debug tracking.[br] +## [br] +## When true, maintains _discovered_changes for debugging.[br] +## Used primarily in tests to verify change detection. +var _is_debug := false + + +## Creates a new guard instance.[br] +## [br] +## [param is_debug] When true, enables change tracking for debugging. +func _init(is_debug := false) -> void: + _is_debug = is_debug + # Register for discovery events to sync the cache + @warning_ignore("return_value_discarded") + GdUnitSignals.instance().gdunit_test_discover_added.connect(sync_test_added) + GdUnitSignals.instance().gdunit_test_discover_deleted.connect(sync_test_deleted) + GdUnitSignals.instance().gdunit_test_discover_modified.connect(sync_test_modified) + GdUnitSignals.instance().gdunit_event.connect(handle_discover_events) + + +## Adds a discovered test to the cache.[br] +## [br] +## [param test_case] The test case to add to the cache. +func sync_test_added(test_case: GdUnitTestCase) -> void: + var test_cases: Array[GdUnitTestCase] = _discover_cache.get_or_add(test_case.source_file, Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)) + test_cases.append(test_case) + + +## Removes a test from the cache.[br] +## [br] +## [param test_case] The test case to remove from the cache. +func sync_test_deleted(test_case: GdUnitTestCase) -> void: + var test_cases: Array[GdUnitTestCase] = _discover_cache.get_or_add(test_case.source_file, Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)) + test_cases.erase(test_case) + + +## Updates a test from the cache.[br] +## [br] +## [param test_case] The test case to update from the cache. +func sync_test_modified(changed_test: GdUnitTestCase) -> void: + var test_cases: Array[GdUnitTestCase] = _discover_cache.get_or_add(changed_test.source_file, Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)) + for test in test_cases: + if test.guid == changed_test.guid: + test.test_name = changed_test.test_name + test.display_name = changed_test.display_name + test.line_number = changed_test.line_number + break + + +## Handles test discovery events.[br] +## [br] +## Resets the cache when a new discovery starts.[br] +## [param event] The discovery event to handle. +func handle_discover_events(event: GdUnitEvent) -> void: + # reset the cache on fresh discovery + if event.type() == GdUnitEvent.DISCOVER_START: + _discover_cache = {} + + +## Registers a callback for discovered tests.[br] +## [br] +## Default sink writes to [class GdUnitTestDiscoverSink]. +static func default_discover_sink(test_case: GdUnitTestCase) -> void: + GdUnitTestDiscoverSink.discover(test_case) + + +## Finds a test case by its unique identifier.[br] +## [br] +## Searches through all cached test cases across all test suites[br] +## to find a test with the matching GUID.[br] +## [br] +## [param id] The GUID of the test to find[br] +## Returns the matching test case or null if not found. +func find_test_by_id(id: GdUnitGUID) -> GdUnitTestCase: + for test_sets: Array[GdUnitTestCase] in _discover_cache.values(): + for test in test_sets: + if test.guid.equals(id): + return test + + return null + + +func get_discovered_tests() -> Array[GdUnitTestCase]: + var discovered_tests: Array[GdUnitTestCase] = [] + for test_sets: Array[GdUnitTestCase] in _discover_cache.values(): + discovered_tests.append_array(test_sets) + return discovered_tests + + +## Discovers tests in a script and tracks changes.[br] +## [br] +## Handles both GDScript and C# test suites.[br] +## The guard maintains test identity through changes.[br] +## [br] +## [param script] The test script to analyze[br] +## [param discover_sink] Optional callback for test discovery events +func discover(script: Script, discover_sink: Callable = default_discover_sink) -> void: + # Verify the script has no errors before run test discovery + var result := script.reload(true) + if result != OK: + return + + if _is_debug: + _discovered_changes["changed_tests"] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase) + _discovered_changes["deleted_tests"] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase) + _discovered_changes["added_tests"] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase) + + if GdUnitTestSuiteScanner.is_test_suite(script): + # for cs scripts we need to recomplie before discover new tests + if script.get_class() == "CSharpScript": + await rebuild_project(script) + + # rediscover all tests + var source_file := script.resource_path + var discovered_tests: Array[GdUnitTestCase] = [] + + GdUnitTestDiscoverer.discover_tests(script, func(test_case: GdUnitTestCase) -> void: + discovered_tests.append(test_case) + ) + + # The suite is never discovered, we add all discovered tests + if not _discover_cache.has(source_file): + for test_case in discovered_tests: + discover_sink.call(test_case) + return + + sync_moved_tests(source_file, discovered_tests) + sync_renamed_tests(source_file, discovered_tests) + sync_deleted_tests(source_file, discovered_tests) + sync_added_tests(source_file, discovered_tests, discover_sink) + + +## Synchronizes moved tests between discover cycles.[br] +## [br] +## A test is considered moved when:[br] +## - It has the same name[br] +## - But a different line number[br] +## [br] +## [param source_file] suite source path[br] +## [param discovered_tests] Newly discovered tests +func sync_moved_tests(source_file: String, discovered_tests: Array[GdUnitTestCase]) -> void: + @warning_ignore("unsafe_method_access") + var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate() + for discovered_test in discovered_tests: + # lookup in cache + var original_tests: Array[GdUnitTestCase] = cache.filter(is_test_moved.bind(discovered_test)) + for test in original_tests: + # update the line_number + var line_number_before := test.line_number + test.line_number = discovered_test.line_number + GdUnitSignals.instance().gdunit_test_discover_modified.emit(test) + if _is_debug: + prints("-> moved test id:%s %s: line:(%d -> %d)" % [test.guid, test.display_name, line_number_before, test.line_number]) + @warning_ignore("unsafe_method_access") + _discovered_changes.get_or_add("changed_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test) + + +## Synchronizes renamed tests between discover cycles.[br] +## [br] +## A test is considered renamed when:[br] +## - It has the same line number[br] +## - But a different name[br] +## [br] +## [param source_file] suite source path[br] +## [param discovered_tests] Newly discovered tests +func sync_renamed_tests(source_file: String, discovered_tests: Array[GdUnitTestCase]) -> void: + @warning_ignore("unsafe_method_access") + var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate() + for discovered_test in discovered_tests: + # lookup in cache + var original_tests: Array[GdUnitTestCase] = cache.filter(is_test_renamed.bind(discovered_test)) + for test in original_tests: + # update the renaming names + var original_display_name := test.display_name + test.test_name = discovered_test.test_name + test.display_name = discovered_test.display_name + GdUnitSignals.instance().gdunit_test_discover_modified.emit(test) + if _is_debug: + prints("-> renamed test id:%s %s -> %s" % [test.guid, original_display_name, test.display_name]) + @warning_ignore("unsafe_method_access") + _discovered_changes.get_or_add("changed_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test) + + +## Synchronizes deleted tests between discover cycles.[br] +## [br] +## A test is considered deleted when:[br] +## - It exists in the cache[br] +## - But is not found in the newly discovered tests[br] +## [br] +## [param source_file] suite source path[br] +## [param discovered_tests] Newly discovered tests +func sync_deleted_tests(source_file: String, discovered_tests: Array[GdUnitTestCase]) -> void: + @warning_ignore("unsafe_method_access") + var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate() + # lookup in cache + for test in cache: + if not discovered_tests.any(test_equals.bind(test)): + GdUnitSignals.instance().gdunit_test_discover_deleted.emit(test) + if _is_debug: + prints("-> deleted test id:%s %s:%d" % [test.guid, test.display_name, test.line_number]) + @warning_ignore("unsafe_method_access") + _discovered_changes.get_or_add("deleted_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test) + + +## Synchronizes newly added tests between discover cycles.[br] +## [br] +## A test is considered added when:[br] +## - It exists in the newly discovered tests[br] +## - But is not found in the cache[br] +## [br] +## [param source_file] suite source path[br] +## [param discovered_tests] Newly discovered tests[br] +## [param discover_sink] Callback to handle newly discovered tests +func sync_added_tests(source_file: String, discovered_tests: Array[GdUnitTestCase], discover_sink: Callable) -> void: + @warning_ignore("unsafe_method_access") + var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate() + # lookup in cache + for test in discovered_tests: + if not cache.any(test_equals.bind(test)): + discover_sink.call(test) + if _is_debug: + prints("-> added test id:%s %s:%d" % [test.guid, test.display_name, test.line_number]) + @warning_ignore("unsafe_method_access") + _discovered_changes.get_or_add("added_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test) + + +func is_test_renamed(left: GdUnitTestCase, right: GdUnitTestCase) -> bool: + return left.line_number == right.line_number and left.test_name != right.test_name + + +func is_test_moved(left: GdUnitTestCase, right: GdUnitTestCase) -> bool: + return left.line_number != right.line_number and left.test_name == right.test_name + + +func test_equals(left: GdUnitTestCase, right: GdUnitTestCase) -> bool: + return left.display_name == right.display_name + + +# do rebuild the entire project, there is actual no way to enforce the Godot engine itself to do this +func rebuild_project(script: Script) -> void: + var class_path := ProjectSettings.globalize_path(script.resource_path) + print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard: CSharpScript change detected on: '%s' [/color]" % class_path) + var scene_tree := Engine.get_main_loop() as SceneTree + await scene_tree.process_frame + + var output := [] + var exit_code := OS.execute("dotnet", ["--version"], output) + if exit_code == -1: + print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=RED]Rebuild the project failed.[/color]") + print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=RED]Can't find installed `dotnet`! Please check your environment is setup correctly.[/color]") + return + + print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=DEEP_SKY_BLUE]Found dotnet v%s[/color]" % str(output[0]).strip_edges()) + output.clear() + + exit_code = OS.execute("dotnet", ["build"], output) + print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=DEEP_SKY_BLUE]Rebuild the project ... [/color]") + for out: String in output: + print_rich("[color=DEEP_SKY_BLUE] %s" % out.strip_edges()) + await scene_tree.process_frame diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid new file mode 100644 index 0000000..2046452 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid @@ -0,0 +1 @@ +uid://bsgm5sxk58e2r diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd new file mode 100644 index 0000000..5d0e5b6 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd @@ -0,0 +1,13 @@ +## A static utility class that acts as a central sink for test case discovery events in GdUnit4. +## Instead of implementing custom sink classes, test discovery consumers should connect to +## the GdUnitSignals.gdunit_test_discovered signal to receive test case discoveries. +## This design allows for a more flexible and decoupled test discovery system. +class_name GdUnitTestDiscoverSink +extends RefCounted + + +## Emits a discovered test case through the GdUnitSignals system.[br] +## Sends the test case to all listeners connected to the gdunit_test_discovered signal.[br] +## [member test_case] The discovered test case to be broadcast to all connected listeners. +static func discover(test_case: GdUnitTestCase) -> void: + GdUnitSignals.instance().gdunit_test_discover_added.emit(test_case) diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid new file mode 100644 index 0000000..22a188c --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid @@ -0,0 +1 @@ +uid://83gmse5cr6qg diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd new file mode 100644 index 0000000..ac2ae69 --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd @@ -0,0 +1,211 @@ +class_name GdUnitTestDiscoverer +extends RefCounted + + +static func run() -> Array[GdUnitTestCase]: + console_log("Running test discovery ..") + await (Engine.get_main_loop() as SceneTree).process_frame + GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new()) + + # We run the test discovery in an extra thread so that the main thread is not blocked + var t:= Thread.new() + @warning_ignore("return_value_discarded") + t.start(func () -> Array[GdUnitTestCase]: + # Loading previous test session + var runner_config := GdUnitRunnerConfig.new() + runner_config.load_config() + var recovered_tests := runner_config.test_cases() + var test_suite_directories := scan_all_test_directories(GdUnitSettings.test_root_folder()) + var scanner := GdUnitTestSuiteScanner.new() + + var collected_tests: Array[GdUnitTestCase] = [] + var collected_test_suites: Array[Script] = [] + # collect test suites + for test_suite_dir in test_suite_directories: + collected_test_suites.append_array(scanner.scan_directory(test_suite_dir)) + + # Do sync the main thread before emit the discovered test suites to the inspector + await (Engine.get_main_loop() as SceneTree).process_frame + for test_suites_script in collected_test_suites: + discover_tests(test_suites_script, func(test_case: GdUnitTestCase) -> void: + # Sync test uid from last test session + recover_test_guid(test_case, recovered_tests) + collected_tests.append(test_case) + GdUnitTestDiscoverSink.discover(test_case) + ) + + console_log_discover_results(collected_tests) + if !recovered_tests.is_empty(): + console_log("Recovered last test session successfully, %d tests restored." % recovered_tests.size(), true) + return collected_tests + ) + # wait unblocked to the tread is finished + while t.is_alive(): + await (Engine.get_main_loop() as SceneTree).process_frame + # needs finally to wait for finish + var test_to_execute: Array[GdUnitTestCase] = await t.wait_to_finish() + GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0)) + return test_to_execute + + +## Restores the last test run session by loading the test run config file and rediscover the tests +static func restore_last_session() -> void: + if GdUnitSettings.is_test_discover_enabled(): + return + + var runner_config := GdUnitRunnerConfig.new() + var result := runner_config.load_config() + # Report possible config loading errors + if result.is_error(): + console_log("Recovery of the last test session failed: %s" % result.error_message(), true) + # If no config file found, skip test recovery + if result.is_warn(): + return + + # If no tests recorded, skip test recovery + var test_cases := runner_config.test_cases() + if test_cases.size() == 0: + return + + # We run the test session restoring in an extra thread so that the main thread is not blocked + var t:= Thread.new() + t.start(func () -> void: + # Do sync the main thread before emit the discovered test suites to the inspector + await (Engine.get_main_loop() as SceneTree).process_frame + console_log("Recovering last test session ..", true) + GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new()) + for test_case in test_cases: + GdUnitTestDiscoverSink.discover(test_case) + GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0)) + console_log("Recovered last test session successfully, %d tests restored." % test_cases.size(), true) + ) + t.wait_to_finish() + + +static func recover_test_guid(current: GdUnitTestCase, recovered_tests: Array[GdUnitTestCase]) -> void: + for recovered_test in recovered_tests: + if recovered_test.fully_qualified_name == current.fully_qualified_name: + current.guid = recovered_test.guid + + +static func console_log_discover_results(tests: Array[GdUnitTestCase]) -> void: + var grouped_by_suites := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String: + return test.source_file + ) + for suite_tests: Array in grouped_by_suites.values(): + var test_case: GdUnitTestCase = suite_tests[0] + console_log("Discover: TestSuite %s with %d tests found" % [test_case.source_file, suite_tests.size()]) + console_log("Discover tests done, %d TestSuites and total %d Tests found. " % [grouped_by_suites.size(), tests.size()]) + console_log("") + + +static func console_log(message: String, on_console := false) -> void: + prints(message) + if on_console: + GdUnitSignals.instance().gdunit_message.emit(message) + + +static func default_discover_sink(test_case: GdUnitTestCase) -> void: + GdUnitTestDiscoverSink.discover(test_case) + + +static func discover_tests(source_script: Script, discover_sink := default_discover_sink) -> void: + if source_script is GDScript: + for test_case in discover_tests_from_gd_script(source_script as GDScript): + discover_sink.call(test_case) + elif source_script.get_class() == "CSharpScript": + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return + for test_case in GdUnit4CSharpApiLoader.discover_tests(source_script): + discover_sink.call(test_case) + + +static func discover_tests_from_gd_script(script: GDScript) -> Array[GdUnitTestCase]: + # Filter by test case only + var test_names: Array[String] = [] + for method: Dictionary in script.get_script_method_list(): + @warning_ignore("unsafe_method_access") + if method["name"].begins_with("test_"): + test_names.append(method["name"]) + if test_names.is_empty(): + return [] + + var source: Node = script.new() + var fds := GdScriptParser.new().get_function_descriptors(script, test_names) + var test_cases: Array[GdUnitTestCase] = [] + for fd in fds: + if fd.is_parameterized(): + test_cases.append_array(discover_parameterised_tests(source, fd)) + else: + test_cases.append(GdUnitTestCase.from(script.resource_path, fd.source_path(), fd.begin_line(), fd.name())) + source.free() + + return test_cases + + +static func discover_parameterised_tests(source: Node, fd: GdFunctionDescriptor) -> Array[GdUnitTestCase]: + var fa := GdFunctionArgument.get_parameter_set(fd.args()) + var parameter_expressions := fa.parameter_sets() + var count := parameter_expressions.size() + + # The parameter set is not static we need to preload it to get the amount of test sets + if count == 0: + var resolver := GdParameterSetResolverFactory.create(fd, source) + if resolver == null: + return [] + count = resolver.get_max_index() + for i in count: + var params := resolver.get_parameters(source, i) + # Strip trailing EMPTY_SET added by the resolver; stringify for display name + parameter_expressions.append(str(params.slice(0, params.size() - 1))) + + var test_cases: Array[GdUnitTestCase] = [] + for parameter_index in count: + var parameter_expression := parameter_expressions[parameter_index] + + @warning_ignore("return_value_discarded") + test_cases.append( + GdUnitTestCase.from( + fd.source_path(), + fd.source_path(), + fd.begin_line(), + fd.name(), + parameter_index, + parameter_expression) + ) + return test_cases + + +static func scan_all_test_directories(root: String) -> PackedStringArray: + var base_directory := "res://" + # If the test root folder is configured as blank, "/", or "res://", use the root folder as described in the settings panel + if root.is_empty() or root == "/" or root == base_directory: + return [base_directory] + return scan_test_directories(base_directory, root, []) + + +static func scan_test_directories(base_directory: String, test_directory: String, test_suite_paths: PackedStringArray) -> PackedStringArray: + print_verbose("Scannning for test directory '%s' at %s" % [test_directory, base_directory]) + for directory in DirAccess.get_directories_at(base_directory): + if directory.begins_with("."): + continue + var current_directory := normalize_path(base_directory + "/" + directory) + if FileAccess.file_exists(current_directory + "/.gdignore"): + continue + if GdUnitTestSuiteScanner.exclude_scan_directories.has(current_directory): + continue + if match_test_directory(directory, test_directory): + @warning_ignore("return_value_discarded") + test_suite_paths.append(current_directory) + else: + @warning_ignore("return_value_discarded") + scan_test_directories(current_directory, test_directory, test_suite_paths) + return test_suite_paths + + +static func normalize_path(path: String) -> String: + return path.replace("///", "//") + + +static func match_test_directory(directory: String, test_directory: String) -> bool: + return directory == test_directory or test_directory.is_empty() or test_directory == "/" or test_directory == "res://" diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid new file mode 100644 index 0000000..cb7598e --- /dev/null +++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid @@ -0,0 +1 @@ +uid://cesrmvdd73l2a diff --git a/addons/gdUnit4/src/core/event/GdUnitEvent.gd b/addons/gdUnit4/src/core/event/GdUnitEvent.gd new file mode 100644 index 0000000..fb5a3a6 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEvent.gd @@ -0,0 +1,207 @@ +class_name GdUnitEvent +extends Resource + +const WARNINGS = "warnings" +const FAILED = "failed" +const FLAKY = "flaky" +const ERRORS = "errors" +const SKIPPED = "skipped" +const ELAPSED_TIME = "elapsed_time" +const ORPHAN_NODES = "orphan_nodes" +const ERROR_COUNT = "error_count" +const FAILED_COUNT = "failed_count" +const SKIPPED_COUNT = "skipped_count" +const RETRY_COUNT = "retry_count" + +enum { + INIT, + STOP, + TESTSUITE_BEFORE, + TESTSUITE_AFTER, + TESTCASE_BEFORE, + TESTCASE_AFTER, + DISCOVER_START, + DISCOVER_END, + SESSION_START, + SESSION_CLOSE +} + +var _event_type: int +var _guid: GdUnitGUID +var _resource_path: String +var _suite_name: String +var _test_name: String +var _total_count: int = 0 +var _statistics := Dictionary() +var _reports: Array[GdUnitReport] = [] + + +func suite_before(p_resource_path: String, p_suite_name: String, p_total_count: int) -> GdUnitEvent: + _guid = GdUnitGUID.new() + _event_type = TESTSUITE_BEFORE + _resource_path = p_resource_path + _suite_name = p_suite_name + _test_name = "before" + _total_count = p_total_count + return self + + +func suite_after(p_resource_path: String, p_suite_name: String, p_statistics: Dictionary = {}, p_reports: Array[GdUnitReport] = []) -> GdUnitEvent: + _guid = GdUnitGUID.new() + _event_type = TESTSUITE_AFTER + _resource_path = p_resource_path + _suite_name = p_suite_name + _test_name = "after" + _statistics = p_statistics + _reports = p_reports + return self + + +func test_before(p_guid: GdUnitGUID) -> GdUnitEvent: + _event_type = TESTCASE_BEFORE + _guid = p_guid + return self + + +func test_after(p_guid: GdUnitGUID, name: String, p_statistics: Dictionary = {}, p_reports :Array[GdUnitReport] = []) -> GdUnitEvent: + _event_type = TESTCASE_AFTER + _guid = p_guid + _test_name = name + _statistics = p_statistics + _reports = p_reports + return self + + +func type() -> int: + return _event_type + + +func guid() -> GdUnitGUID: + return _guid + + +func suite_name() -> String: + return _suite_name + + +func test_name() -> String: + return _test_name + + +func elapsed_time() -> int: + return _statistics.get(ELAPSED_TIME, 0) + + +func orphan_nodes() -> int: + return _statistics.get(ORPHAN_NODES, 0) + + +func statistic(p_type :String) -> int: + return _statistics.get(p_type, 0) + + +func total_count() -> int: + return _total_count + + +func success_count() -> int: + return total_count() - error_count() - failed_count() - skipped_count() + + +func error_count() -> int: + return _statistics.get(ERROR_COUNT, 0) + + +func failed_count() -> int: + return _statistics.get(FAILED_COUNT, 0) + + +func skipped_count() -> int: + return _statistics.get(SKIPPED_COUNT, 0) + + +func retry_count() -> int: + return _statistics.get(RETRY_COUNT, 0) + + +func resource_path() -> String: + return _resource_path + + +func is_success() -> bool: + return not is_failed() and not is_error() + + +func is_warning() -> bool: + return _statistics.get(WARNINGS, false) or orphan_nodes() > 0 + + +func is_failed() -> bool: + return _statistics.get(FAILED, false) + + +func is_error() -> bool: + return _statistics.get(ERRORS, false) + + +func is_flaky() -> bool: + return _statistics.get(FLAKY, false) + + +func is_skipped() -> bool: + return _statistics.get(SKIPPED, false) + + +func reports() -> Array[GdUnitReport]: + return _reports + + +func _to_string() -> String: + return "Event: %s id:%s %s:%s, %s, %s" % [_event_type, _guid, _suite_name, _test_name, _statistics, _reports] + + +func serialize() -> Dictionary: + var serialized := { + "type" : _event_type, + "resource_path": _resource_path, + "suite_name" : _suite_name, + "test_name" : _test_name, + "total_count" : _total_count, + "statistics" : _statistics + } + if _guid != null: + serialized["guid"] = _guid._guid + serialized["reports"] = _serialize_TestReports() + return serialized + + +func deserialize(serialized: Dictionary) -> GdUnitEvent: + _event_type = serialized.get("type", null) + _guid = GdUnitGUID.new(str(serialized.get("guid", ""))) + _resource_path = serialized.get("resource_path", null) + _suite_name = serialized.get("suite_name", null) + _test_name = serialized.get("test_name", "unknown") + _total_count = serialized.get("total_count", 0) + _statistics = serialized.get("statistics", Dictionary()) + if serialized.has("reports"): + # needs this workaround to copy typed values in the array + var reports_to_deserializ :Array[Dictionary] = [] + @warning_ignore("unsafe_cast") + reports_to_deserializ.append_array(serialized.get("reports") as Array) + _reports = _deserialize_reports(reports_to_deserializ) + return self + + +func _serialize_TestReports() -> Array[Dictionary]: + var serialized_reports :Array[Dictionary] = [] + for report in _reports: + serialized_reports.append(report.serialize()) + return serialized_reports + + +func _deserialize_reports(p_reports: Array[Dictionary]) -> Array[GdUnitReport]: + var deserialized_reports :Array[GdUnitReport] = [] + for report in p_reports: + var test_report := GdUnitReport.new().deserialize(report) + deserialized_reports.append(test_report) + return deserialized_reports diff --git a/addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid new file mode 100644 index 0000000..423eb7c --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid @@ -0,0 +1 @@ +uid://dc127am1kug8t diff --git a/addons/gdUnit4/src/core/event/GdUnitEventInit.gd b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd new file mode 100644 index 0000000..774e7d4 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd @@ -0,0 +1,6 @@ +class_name GdUnitInit +extends GdUnitEvent + + +func _init() -> void: + _event_type = INIT diff --git a/addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid new file mode 100644 index 0000000..baf0888 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid @@ -0,0 +1 @@ +uid://b2tfqadava8hb diff --git a/addons/gdUnit4/src/core/event/GdUnitEventStop.gd b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd new file mode 100644 index 0000000..d7a3c11 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd @@ -0,0 +1,6 @@ +class_name GdUnitStop +extends GdUnitEvent + + +func _init() -> void: + _event_type = STOP diff --git a/addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid new file mode 100644 index 0000000..3833c68 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid @@ -0,0 +1 @@ +uid://dp51ferexcut5 diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd new file mode 100644 index 0000000..c6194ef --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd @@ -0,0 +1,19 @@ +class_name GdUnitEventTestDiscoverEnd +extends GdUnitEvent + + +var _total_testsuites: int + + +func _init(testsuite_count: int, test_count: int) -> void: + _event_type = DISCOVER_END + _total_testsuites = testsuite_count + _total_count = test_count + + +func total_test_suites() -> int: + return _total_testsuites + + +func total_tests() -> int: + return _total_count diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid new file mode 100644 index 0000000..2c6af3b --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid @@ -0,0 +1 @@ +uid://cy4y3tgvvtr1x diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd new file mode 100644 index 0000000..c7dd36f --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd @@ -0,0 +1,6 @@ +class_name GdUnitEventTestDiscoverStart +extends GdUnitEvent + + +func _init() -> void: + _event_type = DISCOVER_START diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid new file mode 100644 index 0000000..c379e11 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid @@ -0,0 +1 @@ +uid://nhgrtnn7sh21 diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd new file mode 100644 index 0000000..52dab3f --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd @@ -0,0 +1,6 @@ +class_name GdUnitSessionClose +extends GdUnitEvent + + +func _init() -> void: + _event_type = SESSION_CLOSE diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid new file mode 100644 index 0000000..b2419a3 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid @@ -0,0 +1 @@ +uid://b0js304po2oot diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd new file mode 100644 index 0000000..420ad53 --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd @@ -0,0 +1,6 @@ +class_name GdUnitSessionStart +extends GdUnitEvent + + +func _init() -> void: + _event_type = SESSION_START diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid new file mode 100644 index 0000000..96f178a --- /dev/null +++ b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid @@ -0,0 +1 @@ +uid://bm7ag0crah2b1 diff --git a/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd new file mode 100644 index 0000000..3d024d1 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd @@ -0,0 +1,328 @@ +## The execution context +## It contains all the necessary information about the executed stage, such as memory observers, reports, orphan monitor +class_name GdUnitExecutionContext + +enum GC_ORPHANS_CHECK { + NONE, + SUITE_HOOK_AFTER, + TEST_HOOK_AFTER, + TEST_CASE +} + + +var _parent_context: GdUnitExecutionContext +var _sub_context: Array[GdUnitExecutionContext] = [] +var _orphan_monitor: GdUnitOrphanNodesMonitor +var _memory_observer: GdUnitMemoryObserver +var _report_collector: GdUnitTestReportCollector +var _timer: LocalTime +var _test_case_name: StringName +var _test_case_parameter_set: Array +var _name: String +var _test_execution_iteration: int = 0 +var _flaky_test_check := GdUnitSettings.is_test_flaky_check_enabled() +var _flaky_test_retries := GdUnitSettings.get_flaky_max_retries() +var _last_error: GdUnitError = null + + +var _settings_snapshot: GdUnitProjectSettingsSnapshot = null + + +var error_monitor: GodotGdErrorMonitor = null: + get: + if _parent_context != null: + return _parent_context.error_monitor + if error_monitor == null: + error_monitor = GodotGdErrorMonitor.new() + return error_monitor + + +var test_suite: GdUnitTestSuite = null: + get: + if _parent_context != null: + return _parent_context.test_suite + return test_suite + + +var test_case: _TestCase = null: + get: + if test_case == null and _parent_context != null: + return _parent_context.test_case + return test_case + + +func _init(name: StringName, parent_context: GdUnitExecutionContext = null) -> void: + _name = name + _parent_context = parent_context + _timer = LocalTime.now() + _orphan_monitor = GdUnitOrphanNodesMonitor.new(name) + + if parent_context != null: + parent_context._orphan_monitor.add_child_monitor(_orphan_monitor) + orphan_monitor_start() + + _memory_observer = GdUnitMemoryObserver.new() + _report_collector = GdUnitTestReportCollector.new() + if parent_context != null: + parent_context._sub_context.append(self) + + +func dispose() -> void: + if test_suite != null: + test_suite.free() + _timer = null + _orphan_monitor = null + _report_collector = null + _memory_observer = null + _parent_context = null + test_suite = null + test_case = null + dispose_sub_contexts() + + +func dispose_sub_contexts() -> void: + for context in _sub_context: + context.dispose() + _sub_context.clear() + + +func last_sub_context() -> GdUnitExecutionContext: + return null if _sub_context.is_empty() else _sub_context[-1] + + +func terminate() -> void: + if test_case: + test_case.do_terminate() + + +static func of(pe: GdUnitExecutionContext) -> GdUnitExecutionContext: + var context := GdUnitExecutionContext.new(pe._test_case_name, pe) + context._test_case_name = pe._test_case_name + context._test_execution_iteration = pe._test_execution_iteration + return context + + +static func of_test_case(pe: GdUnitExecutionContext, p_test_case: _TestCase) -> GdUnitExecutionContext: + assert(p_test_case, "test_case is null") + var context := GdUnitExecutionContext.new(p_test_case.test_name(), pe) + context.test_case = p_test_case + return context + + +static func of_parameterized_test(pe: GdUnitExecutionContext, test_case_name: String, test_case_parameter_set: Array) -> GdUnitExecutionContext: + var context := GdUnitExecutionContext.new(test_case_name, pe) + context._test_case_name = test_case_name + context._test_case_parameter_set = test_case_parameter_set + return context + + +func get_test_suite_path() -> String: + return test_suite.get_script().resource_path + + +func get_test_suite_name() -> StringName: + return test_suite.get_name() + + +func get_test_case_name() -> StringName: + if _test_case_name.is_empty(): + return test_case._test_case.display_name + return _test_case_name + + +func save_project_settings() -> void: + if _settings_snapshot == null: + _settings_snapshot = GdUnitProjectSettingsSnapshot.new() + _settings_snapshot.save() + + +func restore_project_settings() -> void: + if _settings_snapshot == null: + return + _settings_snapshot.restore() + + +func error_monitor_start() -> void: + error_monitor.start() + + +func error_monitor_stop() -> void: + error_monitor.stop() + for error_report in error_monitor.to_reports(): + if error_report.is_error(): + _report_collector.push_back(error_report) + + +func orphan_monitor_start() -> void: + _orphan_monitor.start() + + +func orphan_monitor_collect() -> void: + _orphan_monitor.collect() + + +func orphan_monitor_stop() -> void: + _orphan_monitor.stop() + + +func add_report(report: GdUnitReport) -> GdUnitReport: + _report_collector.push_back(report) + return report + + +func reports() -> Array[GdUnitReport]: + return _report_collector.reports() + + +func set_error(error: GdUnitError) -> void: + _last_error = error + + +func last_error() -> GdUnitError: + if _last_error != null: + return _last_error + + if _report_collector.reports().is_empty(): + return null + + var last_report: GdUnitReport = _report_collector.reports()[-1] + return last_report._error if last_report != null else _last_error + + +func collect_reports(recursive: bool) -> Array[GdUnitReport]: + if not recursive: + return reports() + + # we combine the reports of test_before(), test_after() and test() to be reported by `fire_test_ended` + # we strictly need to copy the reports before adding sub context reports to avoid manipulation of the current context + var current_reports := reports().duplicate() + for sub_context in _sub_context: + current_reports.append_array(sub_context.collect_reports(true)) + + return current_reports + + +func calculate_statistics(reports_: Array[GdUnitReport]) -> Dictionary: + var failed_count := GdUnitTestReportCollector.count_failures(reports_) + var error_count := GdUnitTestReportCollector.count_errors(reports_) + var warn_count := GdUnitTestReportCollector.count_warnings(reports_) + var skip_count := GdUnitTestReportCollector.count_skipped(reports_) + var orphan_count := GdUnitTestReportCollector.count_orphans(reports_) + var is_failed := !is_success() + var elapsed_time := _timer.elapsed_since_ms() + var retries := 1 if _parent_context == null else _sub_context.size() + # Mark as flaky if it is successful, but errors were counted + var is_flaky := retries > 1 and not is_failed + # In the case of a flakiness test, we do not report an error counter, as an unreliable test is considered successful + # after a certain number of repetitions. + if is_flaky: + failed_count = 0 + + return { + GdUnitEvent.RETRY_COUNT: retries, + GdUnitEvent.ELAPSED_TIME: elapsed_time, + GdUnitEvent.FAILED: is_failed, + GdUnitEvent.ERRORS: error_count > 0, + GdUnitEvent.WARNINGS: warn_count > 0, + GdUnitEvent.FLAKY: is_flaky, + GdUnitEvent.SKIPPED: skip_count > 0, + GdUnitEvent.FAILED_COUNT: failed_count, + GdUnitEvent.ERROR_COUNT: error_count, + GdUnitEvent.SKIPPED_COUNT: skip_count, + GdUnitEvent.ORPHAN_NODES: orphan_count, + } + + +func is_success() -> bool: + if _sub_context.is_empty(): + return not _report_collector.has_failures() + # we on test suite level? + if _parent_context == null: + return not _report_collector.has_failures() + + return _sub_context[-1].is_success() and not _report_collector.has_failures() + + +func is_skipped() -> bool: + return ( + _sub_context.any(func(c :GdUnitExecutionContext) -> bool: + return c.is_skipped()) + or test_case.is_skipped() if test_case != null else false + ) + + +func is_interupted() -> bool: + return false if test_case == null else test_case.is_interupted() + + +func sum(accum: int, number: int) -> int: + return accum + number + + +func retry_execution() -> bool: + var retry := _test_execution_iteration < 1 if not _flaky_test_check else _test_execution_iteration < _flaky_test_retries + if retry: + _test_execution_iteration += 1 + return retry + + +func register_auto_free(obj: Variant) -> Variant: + return _memory_observer.register_auto_free(obj) + + +## Runs the gdunit garbage collector to free registered object and handle orphan node reporting +func gc(gc_orphan_check: GC_ORPHANS_CHECK = GC_ORPHANS_CHECK.NONE) -> void: + # unreference last used assert form the test to prevent memory leaks + GdUnitThreadManager.get_current_context().clear_assert() + await _memory_observer.gc() + orphan_monitor_stop() + if _orphan_monitor.orphans_count() > 0: + # Give the engine time to free resources otherwies we do orphan false detection + await test_suite.get_tree().process_frame + + match(gc_orphan_check): + GC_ORPHANS_CHECK.SUITE_HOOK_AFTER: + report_ophans(-1, """ + func before() -> void: + collect_orphan_node_details()) + + func after() -> void: + collect_orphan_node_details())""") + + GC_ORPHANS_CHECK.TEST_HOOK_AFTER: + report_ophans(-1, """ + func before_test() -> void: + collect_orphan_node_details() + + func after_test() -> void: + collect_orphan_node_details()""") + + GC_ORPHANS_CHECK.TEST_CASE: + report_ophans(test_case.line_number(), + " collect_orphan_node_details()" + ) + + + +func report_ophans(line_number: int, additonal_info := "") -> void: + var orphan_infos := _orphan_monitor.detected_orphans() + if orphan_infos.is_empty(): + var orphans_count := _orphan_monitor.orphans_count() + if orphans_count > 0: + reports().push_back(GdUnitReport.new() \ + .create(GdUnitReport.ORPHAN, line_number, GdAssertMessages.orphan_warning(orphans_count, additonal_info)) + .with_current_value(orphans_count)) + else: + reports() \ + .push_back(GdUnitReport.new()\ + .create(GdUnitReport.ORPHAN, line_number, GdAssertMessages.orphan_detected(orphan_infos.size())) \ + .with_current_value(orphan_infos.size())) + + for orphan_info in orphan_infos: + var error := GdUnitError.new( + GdAssertMessages.orphan_node_info(orphan_info), + 0, + GdUnitStackTrace.new([orphan_info._stack_element]) if orphan_info._stack_element != null else null) + reports().push_back(GdUnitReport.new()\ + .from_error(GdUnitReport.ORPHAN, error) + .with_current_value(0)) diff --git a/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid new file mode 100644 index 0000000..35e678c --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid @@ -0,0 +1 @@ +uid://blax5gquq8bky diff --git a/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd new file mode 100644 index 0000000..16ded21 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd @@ -0,0 +1,133 @@ +## The memory watcher for objects that have been registered and are released when 'gc' is called. +class_name GdUnitMemoryObserver +extends RefCounted + +const TAG_OBSERVE_INSTANCE := "GdUnit4_observe_instance_" +const TAG_AUTO_FREE = "GdUnit4_marked_auto_free" +const GdUnitTools = preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + + +var _store :Array[Variant] = [] +# enable for debugging purposes +var _is_stdout_verbose := false +const _show_debug := false + + +## Registration of an instance to be released when an execution phase is completed +func register_auto_free(obj :Variant) -> Variant: + if not is_instance_valid(obj): + return obj + # do not register on GDScriptNativeClass + @warning_ignore("unsafe_cast") + if typeof(obj) == TYPE_OBJECT and (obj as Object).is_class("GDScriptNativeClass") : + return obj + #if obj is GDScript or obj is ScriptExtension: + # return obj + if obj is MainLoop: + push_error("GdUnit4: Avoid to add mainloop to auto_free queue %s" % obj) + return + if _is_stdout_verbose: + print_verbose("GdUnit4:gc():register auto_free(%s)" % obj) + # only register pure objects + if obj is GdUnitSceneRunner: + _store.push_back(obj) + else: + _store.append(obj) + _tag_object(obj) + return obj + + +# to disable instance guard when run into issues. +static func _is_instance_guard_enabled() -> bool: + return false + + +static func debug_observe(name :String, obj :Object, indent :int = 0) -> void: + if not _show_debug: + return + var script :GDScript= obj if obj is GDScript else obj.get_script() + if script: + var base_script :GDScript = script.get_base_script() + @warning_ignore("unsafe_method_access") + prints("".lpad(indent, " "), name, obj, obj.get_class(), "reference_count:", obj.get_reference_count() if obj is RefCounted else 0, "script:", script, script.resource_path) + if base_script: + debug_observe("+", base_script, indent+1) + else: + @warning_ignore("unsafe_method_access") + prints(name, obj, obj.get_class(), obj.get_name()) + + +static func guard_instance(obj :Object) -> void: + if not _is_instance_guard_enabled(): + return + var tag := TAG_OBSERVE_INSTANCE + str(abs(obj.get_instance_id())) + if Engine.has_meta(tag): + return + debug_observe("Gard on instance", obj) + Engine.set_meta(tag, obj) + + +static func unguard_instance(obj :Object, verbose := true) -> void: + if not _is_instance_guard_enabled(): + return + var tag := TAG_OBSERVE_INSTANCE + str(abs(obj.get_instance_id())) + if verbose: + debug_observe("unguard instance", obj) + if Engine.has_meta(tag): + Engine.remove_meta(tag) + + +static func gc_guarded_instance(name :String, instance :Object) -> void: + if not _is_instance_guard_enabled(): + return + await (Engine.get_main_loop() as SceneTree).process_frame + unguard_instance(instance, false) + if is_instance_valid(instance) and instance is RefCounted: + # finally do this very hacky stuff + # we need to manually unreferece to avoid leaked scripts + # but still leaked GDScriptFunctionState exists + #var script :GDScript = instance.get_script() + #if script: + # var base_script :GDScript = script.get_base_script() + # if base_script: + # base_script.unreference() + debug_observe(name, instance) + (instance as RefCounted).unreference() + await (Engine.get_main_loop() as SceneTree).process_frame + + +static func gc_on_guarded_instances() -> void: + if not _is_instance_guard_enabled(): + return + for tag in Engine.get_meta_list(): + if tag.begins_with(TAG_OBSERVE_INSTANCE): + var instance :Object = Engine.get_meta(tag) + await gc_guarded_instance("Leaked instance detected:", instance) + await GdUnitTools.free_instance(instance, false) + + +# store the object into global store aswell to be verified by 'is_marked_auto_free' +func _tag_object(obj :Variant) -> void: + var tagged_object: Array = Engine.get_meta(TAG_AUTO_FREE, []) + tagged_object.append(obj) + Engine.set_meta(TAG_AUTO_FREE, tagged_object) + + +## Runs over all registered objects and releases them +func gc() -> void: + if _store.is_empty(): + return + if _is_stdout_verbose: + print_verbose("GdUnit4:gc():running", " freeing %d objects .." % _store.size()) + var tagged_objects: Array = Engine.get_meta(TAG_AUTO_FREE, []) + while not _store.is_empty(): + var value :Variant = _store.pop_front() + tagged_objects.erase(value) + await GdUnitTools.free_instance(value, _is_stdout_verbose) + assert(_store.is_empty(), "The memory observer has still entries in the store!") + + +## Checks whether the specified object is registered for automatic release +static func is_marked_auto_free(obj: Variant) -> bool: + var tagged_objects: Array = Engine.get_meta(TAG_AUTO_FREE, []) + return tagged_objects.has(obj) diff --git a/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid new file mode 100644 index 0000000..b69fb75 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid @@ -0,0 +1 @@ +uid://bx2w3g47asy2x diff --git a/addons/gdUnit4/src/core/execution/GdUnitProjectSettingsSnapshot.gd b/addons/gdUnit4/src/core/execution/GdUnitProjectSettingsSnapshot.gd new file mode 100644 index 0000000..af7336d --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitProjectSettingsSnapshot.gd @@ -0,0 +1,36 @@ +## Captures and restores all in-memory [ProjectSettings] values around a single +## stage of test execution.[br] +## Each [GdUnitExecutionContext] owns one instance. Suite-level and test-level +## isolation are achieved by the two separate contexts rather than by stacking:[br] +## [codeblock] +## GdUnitTestSuiteExecutionStage -> save() (before before()) +## GdUnitTestCaseExecutionStage -> save() (before before_test()) +## GdUnitTestCaseExecutionStage -> restore() (after after_test()) +## GdUnitTestSuiteExecutionStage -> restore() (after after()) +## [/codeblock] +class_name GdUnitProjectSettingsSnapshot +extends RefCounted + + +var _snapshot: Dictionary = {} + + +func save() -> void: + _snapshot.clear() + for property: Dictionary in ProjectSettings.get_property_list(): + var name: String = property["name"] + if ProjectSettings.has_setting(name): + var value: Variant = ProjectSettings.get_setting(name) + @warning_ignore("unsafe_method_access") + _snapshot[name] = value.duplicate() if (value is Dictionary or value is Array) else value + + +func restore() -> void: + for name: String in _snapshot: + if not ProjectSettings.has_setting(name): + continue + var current: Variant = ProjectSettings.get_setting(name) + var original: Variant = _snapshot[name] + if current != original: + ProjectSettings.set_setting(name, original) + _snapshot.clear() diff --git a/addons/gdUnit4/src/core/execution/GdUnitProjectSettingsSnapshot.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitProjectSettingsSnapshot.gd.uid new file mode 100644 index 0000000..840b95e --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitProjectSettingsSnapshot.gd.uid @@ -0,0 +1 @@ +uid://cqklt1x6xnjw4 diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd new file mode 100644 index 0000000..91247bd --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd @@ -0,0 +1,77 @@ +# Collects all reports seperated as warnings, failures and errors +class_name GdUnitTestReportCollector +extends RefCounted + + +var _reports :Array[GdUnitReport] = [] + + +static func __filter_is_error(report :GdUnitReport) -> bool: + return report.is_error() + + +static func __filter_is_failure(report :GdUnitReport) -> bool: + return report.is_failure() + + +static func __filter_is_warning(report :GdUnitReport) -> bool: + return report.is_warning() + + +static func __filter_is_skipped(report :GdUnitReport) -> bool: + return report.is_skipped() + + +static func __filter_is_orphan(report: GdUnitReport) -> bool: + return report.is_orphan() + + +static func count_failures(reports_: Array[GdUnitReport]) -> int: + return reports_.filter(__filter_is_failure).size() + + +static func count_errors(reports_: Array[GdUnitReport]) -> int: + return reports_.filter(__filter_is_error).size() + + +static func count_warnings(reports_: Array[GdUnitReport]) -> int: + return reports_.filter(__filter_is_warning).size() + + +static func count_skipped(reports_: Array[GdUnitReport]) -> int: + return reports_.filter(__filter_is_skipped).size() + + +static func count_orphans(reports_: Array[GdUnitReport]) -> int: + var orphan_reports := reports_.filter(__filter_is_orphan) + if orphan_reports.is_empty(): + return 0 + ## Collect orphan count from the reports + var orphans := 0 + for report: GdUnitReport in orphan_reports: + orphans += report._current_value + return orphans + + +func has_failures() -> bool: + return _reports.any(__filter_is_failure) + + +func has_errors() -> bool: + return _reports.any(__filter_is_error) + + +func has_warnings() -> bool: + return _reports.any(__filter_is_warning) + + +func has_skipped() -> bool: + return _reports.any(__filter_is_skipped) + + +func reports() -> Array[GdUnitReport]: + return _reports + + +func push_back(report :GdUnitReport) -> void: + _reports.push_back(report) diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid new file mode 100644 index 0000000..56aba02 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid @@ -0,0 +1 @@ +uid://c2jxeeu7crewq diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd new file mode 100644 index 0000000..96268f8 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd @@ -0,0 +1,57 @@ +## The executor to run a test-suite +class_name GdUnitTestSuiteExecutor + + +# preload all asserts here +@warning_ignore("unused_private_class_variable") +var _assertions := GdUnitAssertions.new() +var _executeStage := GdUnitTestSuiteExecutionStage.new() +var _debug_mode : bool +var _terminated := false + +func _init(debug_mode: bool = false) -> void: + _executeStage.set_debug_mode(debug_mode) + _debug_mode = debug_mode + GdUnitSignals.instance().gdunit_test_session_terminate.connect(_on_testsession_terminated) + + +func _on_testsession_terminated() -> void: + _terminated = true + GdUnitThreadManager.interrupt() + + +func run_and_wait(tests: Array[GdUnitTestCase]) -> void: + if !_debug_mode: + GdUnitSignals.instance().gdunit_event.emit(GdUnitInit.new()) + + var orphan_detection_enabled := GdUnitSettings.is_verbose_orphans() + if not orphan_detection_enabled: + prints("!!! Reporting orphan nodes is disabled. Please check GdUnit settings.") + + # first we group all tests by resource path + var grouped_by_suites := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String: + return test.suite_resource_path + ) + var scanner := GdUnitTestSuiteScanner.new() + for suite_path: String in grouped_by_suites.keys(): + if _terminated: + break + @warning_ignore("unsafe_call_argument") + var suite_tests: Array[GdUnitTestCase] = Array(grouped_by_suites[suite_path], TYPE_OBJECT, "RefCounted", GdUnitTestCase) + var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(suite_path) + if script.get_class() == "GDScript": + var context := GdUnitExecutionContext.new(suite_path) + GdUnitThreadManager.get_current_context().set_execution_context(context) + var test_suite := scanner.load_suite(script as GDScript, suite_tests) + context.test_suite = test_suite + (Engine.get_main_loop() as SceneTree).root.add_child(test_suite) + await _executeStage.execute(context) + context.dispose() + else: + await GdUnit4CSharpApiLoader.execute(suite_tests) + if !_debug_mode: + GdUnitSignals.instance().gdunit_event.emit(GdUnitStop.new()) + + +func fail_fast(enabled :bool) -> void: + _executeStage.fail_fast(enabled) diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid new file mode 100644 index 0000000..3bbcf7b --- /dev/null +++ b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid @@ -0,0 +1 @@ +uid://dxw3ui3wl6uox diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd new file mode 100644 index 0000000..a0b5194 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd @@ -0,0 +1,26 @@ +## The test case shutdown hook implementation.[br] +## It executes the 'test_after()' block from the test-suite. +class_name GdUnitTestCaseAfterStage +extends IGdUnitExecutionStage + + +var _call_stage: bool + + +func _init(call_stage := true) -> void: + _call_stage = call_stage + + +func _execute(context: GdUnitExecutionContext) -> void: + var test_suite := context.test_suite + + if _call_stage: + @warning_ignore("redundant_await") + await test_suite.after_test() + + var test_case_context := context.last_sub_context() + if test_case_context != null: + await test_case_context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_CASE) + + await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_HOOK_AFTER) + context.error_monitor_stop() diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid new file mode 100644 index 0000000..30022fb --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid @@ -0,0 +1 @@ +uid://bxjd8iwxbpfcw diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd new file mode 100644 index 0000000..d7790a9 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd @@ -0,0 +1,20 @@ +## The test case startup hook implementation.[br] +## It executes the 'test_before()' block from the test-suite. +class_name GdUnitTestCaseBeforeStage +extends IGdUnitExecutionStage + +var _call_stage :bool + + +func _init(call_stage := true) -> void: + _call_stage = call_stage + + +func _execute(context: GdUnitExecutionContext) -> void: + var test_suite := context.test_suite + + if _call_stage: + @warning_ignore("redundant_await") + await test_suite.before_test() + + context.error_monitor_start() diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid new file mode 100644 index 0000000..4e042f9 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid @@ -0,0 +1 @@ +uid://dop6wpw8saakg diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd new file mode 100644 index 0000000..ea993bd --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd @@ -0,0 +1,41 @@ +## The test case execution stage.[br] +class_name GdUnitTestCaseExecutionStage +extends IGdUnitExecutionStage + + +var _stage_single_test: IGdUnitExecutionStage = GdUnitTestCaseSingleExecutionStage.new() +var _stage_fuzzer_test: IGdUnitExecutionStage = GdUnitTestCaseFuzzedExecutionStage.new() + + +## Executes the test case 'test_()'.[br] +## It executes synchronized following stages[br] +## -> test_before() [br] +## -> test_case() [br] +## -> test_after() [br] +@warning_ignore("redundant_await") +func _execute(context :GdUnitExecutionContext) -> void: + var test_case := context.test_case + + if GdUnitSettings.is_project_settings_auto_save(): + context.save_project_settings() + context.error_monitor_start() + + if test_case.is_fuzzed(): + await _stage_fuzzer_test.execute(context) + else: + await _stage_single_test.execute(context) + + await context.gc() + context.error_monitor_stop() + if GdUnitSettings.is_project_settings_auto_save(): + context.restore_project_settings() + + # finally free the test instance + if is_instance_valid(context.test_case): + context.test_case.dispose() + + +func set_debug_mode(debug_mode :bool = false) -> void: + super.set_debug_mode(debug_mode) + _stage_single_test.set_debug_mode(debug_mode) + _stage_fuzzer_test.set_debug_mode(debug_mode) diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid new file mode 100644 index 0000000..7f940a0 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://fe74vddrwyyc diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd new file mode 100644 index 0000000..03bbd0f --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd @@ -0,0 +1,29 @@ +## The test suite shutdown hook implementation.[br] +## It executes the 'after()' block from the test-suite. +class_name GdUnitTestSuiteAfterStage +extends IGdUnitExecutionStage + + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + + +func _execute(context :GdUnitExecutionContext) -> void: + var test_suite := context.test_suite + + @warning_ignore("redundant_await") + await test_suite.after() + await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.SUITE_HOOK_AFTER) + + var reports := context.collect_reports(false) + var statistics := context.calculate_statistics(reports) + fire_event(GdUnitEvent.new()\ + .suite_after(context.get_test_suite_path(),\ + test_suite.get_name(), + statistics, + reports)) + GdUnitFileAccess.clear_tmp() + # Guard that checks if all doubled (spy/mock) objects are released + await GdUnitClassDoubler.check_leaked_instances() + # we hide the scene/main window after runner is finished + if not Engine.is_embedded_in_editor(): + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED) diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid new file mode 100644 index 0000000..b638ea1 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid @@ -0,0 +1 @@ +uid://d2vfpfjqo1xxp diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd new file mode 100644 index 0000000..a6ed8c5 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd @@ -0,0 +1,14 @@ +## The test suite startup hook implementation.[br] +## It executes the 'before()' block from the test-suite. +class_name GdUnitTestSuiteBeforeStage +extends IGdUnitExecutionStage + + +func _execute(context: GdUnitExecutionContext) -> void: + var test_suite := context.test_suite + + fire_event(GdUnitEvent.new()\ + .suite_before(context.get_test_suite_path(), test_suite.get_name(), test_suite.get_child_count())) + + @warning_ignore("redundant_await") + await test_suite.before() diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid new file mode 100644 index 0000000..d7bad64 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid @@ -0,0 +1 @@ +uid://dwl8piswl74nc diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd new file mode 100644 index 0000000..bbd315d --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd @@ -0,0 +1,153 @@ +## The test suite main execution stage.[br] +class_name GdUnitTestSuiteExecutionStage +extends IGdUnitExecutionStage + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +var _stage_before :IGdUnitExecutionStage = GdUnitTestSuiteBeforeStage.new() +var _stage_after :IGdUnitExecutionStage = GdUnitTestSuiteAfterStage.new() +var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseExecutionStage.new() +var _fail_fast := false + + +## Executes all tests of an test suite.[br] +## It executes synchronized following stages[br] +## -> before() [br] +## -> run all test cases [br] +## -> after() [br] +func _execute(context :GdUnitExecutionContext) -> void: + if context.test_suite.__is_skipped: + await fire_test_suite_skipped(context) + else: + # Start with a fresh frame to avoid inheriting a large delta from suite loading/teardown. + await (Engine.get_main_loop() as SceneTree).process_frame + @warning_ignore("return_value_discarded") + GdUnitMemoryObserver.guard_instance(context.test_suite.__awaiter) + if GdUnitSettings.is_project_settings_auto_save(): + context.save_project_settings() + await _stage_before.execute(context) + for test_case_index in context.test_suite.get_child_count(): + # iterate only over test cases + var test_case := context.test_suite.get_child(test_case_index) as _TestCase + if not is_instance_valid(test_case): + continue + context.test_suite.set_active_test_case(test_case.test_name()) + var test_case_context := GdUnitExecutionContext.of_test_case(context, test_case) + await _stage_test.execute(test_case_context) + # stop on first error or if fail fast is enabled + if test_case.is_terminated() or (_fail_fast and not test_case_context.is_success()): + break + if test_case.is_interupted(): + # it needs to go this hard way to kill the outstanding awaits of a test case when the test timed out + # we delete the current test suite where is execute the current test case to kill the function state + # and replace it by a clone without function state + context.test_suite = await clone_test_suite(context.test_suite) + await _stage_after.execute(context) + if GdUnitSettings.is_project_settings_auto_save(): + context.restore_project_settings() + GdUnitMemoryObserver.unguard_instance(context.test_suite.__awaiter) + + await (Engine.get_main_loop() as SceneTree).process_frame + + +# clones a test suite and moves the test cases to new instance +func clone_test_suite(test_suite :GdUnitTestSuite) -> GdUnitTestSuite: + await (Engine.get_main_loop() as SceneTree).process_frame + dispose_timers(test_suite) + await GdUnitMemoryObserver.gc_guarded_instance("Manually free on awaiter", test_suite.__awaiter) + var parent := test_suite.get_parent() + var _test_suite := GdUnitTestSuite.new() + parent.remove_child(test_suite) + copy_properties(test_suite, _test_suite) + for child in test_suite.get_children(): + test_suite.remove_child(child) + _test_suite.add_child(child) + parent.add_child(_test_suite) + @warning_ignore("return_value_discarded") + GdUnitMemoryObserver.guard_instance(_test_suite.__awaiter) + # finally free current test suite instance + test_suite.free() + await (Engine.get_main_loop() as SceneTree).process_frame + return _test_suite + + +func dispose_timers(test_suite :GdUnitTestSuite) -> void: + GdUnitTools.release_timers() + for child in test_suite.get_children(): + if child is Timer: + (child as Timer).stop() + test_suite.remove_child(child) + child.free() + + +func copy_properties(source :Object, target :Object) -> void: + if not source is _TestCase and not source is GdUnitTestSuite: + return + for property in source.get_property_list(): + var property_name :String = property["name"] + if property_name == "__awaiter": + continue + target.set(property_name, source.get(property_name)) + + +func fire_test_suite_skipped(context :GdUnitExecutionContext) -> void: + var test_suite := context.test_suite + var skip_count := test_suite.get_child_count() + fire_event(GdUnitEvent.new()\ + .suite_before(context.get_test_suite_path(), test_suite.get_name(), skip_count)) + + + for test_case_index in context.test_suite.get_child_count(): + # iterate only over test cases + var test_case := context.test_suite.get_child(test_case_index) as _TestCase + if not is_instance_valid(test_case): + continue + var test_case_context := GdUnitExecutionContext.of_test_case(context, test_case) + fire_event(GdUnitEvent.new().test_before(test_case.id())) + # use skip count 0 because we counted it over the complete test suite + fire_test_skipped(test_case_context, 0) + + + var statistics := { + GdUnitEvent.ORPHAN_NODES: 0, + GdUnitEvent.ELAPSED_TIME: 0, + GdUnitEvent.WARNINGS: false, + GdUnitEvent.ERRORS: false, + GdUnitEvent.ERROR_COUNT: 0, + GdUnitEvent.FAILED: false, + GdUnitEvent.FAILED_COUNT: 0, + GdUnitEvent.SKIPPED_COUNT: skip_count, + GdUnitEvent.SKIPPED: true + } + var report := GdUnitReport.new().create(GdUnitReport.SKIPPED, -1, GdAssertMessages.test_suite_skipped(test_suite.__skip_reason, skip_count)) + fire_event(GdUnitEvent.new().suite_after(context.get_test_suite_path(), test_suite.get_name(), statistics, [report])) + await (Engine.get_main_loop() as SceneTree).process_frame + + +func fire_test_skipped(context: GdUnitExecutionContext, skip_count := 1) -> void: + var test_case := context.test_case + var statistics := { + GdUnitEvent.ORPHAN_NODES: 0, + GdUnitEvent.ELAPSED_TIME: 0, + GdUnitEvent.WARNINGS: false, + GdUnitEvent.ERRORS: false, + GdUnitEvent.ERROR_COUNT: 0, + GdUnitEvent.FAILED: false, + GdUnitEvent.FAILED_COUNT: 0, + GdUnitEvent.SKIPPED: true, + GdUnitEvent.SKIPPED_COUNT: skip_count, + } + var report := GdUnitReport.new() \ + .create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped("Skipped from the entire test suite")) + fire_event(GdUnitEvent.new().test_after(test_case.id(), test_case.test_name(), statistics, [report])) + + +func set_debug_mode(debug_mode :bool = false) -> void: + super.set_debug_mode(debug_mode) + _stage_before.set_debug_mode(debug_mode) + _stage_after.set_debug_mode(debug_mode) + _stage_test.set_debug_mode(debug_mode) + + +func fail_fast(enabled :bool) -> void: + _fail_fast = enabled diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid new file mode 100644 index 0000000..682938b --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://dqjyba14jmomv diff --git a/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd new file mode 100644 index 0000000..a04f7e7 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd @@ -0,0 +1,37 @@ +## The interface of execution stage.[br] +## An execution stage is defined as an encapsulated task that can execute 1-n substages covered by its own execution context.[br] +## Execution stage are always called synchronously. +@abstract class_name IGdUnitExecutionStage +extends RefCounted + +var _debug_mode := false + + +## Executes synchronized the implemented stage in its own execution context.[br] +## example:[br] +## [codeblock] +## # waits for 100ms +## await MyExecutionStage.new().execute() +## [/codeblock][br] +func execute(context: GdUnitExecutionContext) -> void: + GdUnitThreadManager.get_current_context().set_execution_context(context) + @warning_ignore("redundant_await") + await _execute(context) + + +## Sends the event to registered listeners +func fire_event(event: GdUnitEvent) -> void: + if _debug_mode: + GdUnitSignals.instance().gdunit_event_debug.emit(event) + else: + GdUnitSignals.instance().gdunit_event.emit(event) + + +## Internal testing stuff.[br] +## Sets the executor into debug mode to emit `GdUnitEvent` via signal `gdunit_event_debug` +func set_debug_mode(debug_mode: bool) -> void: + _debug_mode = debug_mode + + +## The execution phase to be carried out. +@abstract func _execute(context: GdUnitExecutionContext) -> void diff --git a/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid new file mode 100644 index 0000000..8ccf157 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://iqcu4v2pqoxx diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd new file mode 100644 index 0000000..53cbcdd --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd @@ -0,0 +1,52 @@ +## The test case execution stage.[br] +class_name GdUnitTestCaseFuzzedExecutionStage +extends IGdUnitExecutionStage + +var _stage_before :IGdUnitExecutionStage = GdUnitTestCaseBeforeStage.new(false) +var _stage_after :IGdUnitExecutionStage = GdUnitTestCaseAfterStage.new(false) +var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseFuzzedTestStage.new() + + +func _execute(context :GdUnitExecutionContext) -> void: + fire_event(GdUnitEvent.new().test_before(context.test_case.id())) + + while context.retry_execution(): + var test_context := GdUnitExecutionContext.of(context) + await _stage_before.execute(test_context) + if not context.test_case.is_skipped(): + await _stage_test.execute(GdUnitExecutionContext.of(test_context)) + await _stage_after.execute(test_context) + if test_context.is_success() or test_context.is_skipped() or test_context.is_interupted(): + break + + await context.gc() + if context.is_skipped(): + fire_test_skipped(context) + else: + var reports: = context.collect_reports(true) + var statistics := context.calculate_statistics(reports) + fire_event(GdUnitEvent.new().test_after(context.test_case.id(), context.test_case.test_name(), statistics, reports)) + +func set_debug_mode(debug_mode :bool = false) -> void: + super.set_debug_mode(debug_mode) + _stage_before.set_debug_mode(debug_mode) + _stage_after.set_debug_mode(debug_mode) + _stage_test.set_debug_mode(debug_mode) + + +func fire_test_skipped(context: GdUnitExecutionContext) -> void: + var test_case := context.test_case + var statistics := { + GdUnitEvent.ORPHAN_NODES: 0, + GdUnitEvent.ELAPSED_TIME: 0, + GdUnitEvent.WARNINGS: false, + GdUnitEvent.ERRORS: false, + GdUnitEvent.ERROR_COUNT: 0, + GdUnitEvent.FAILED: false, + GdUnitEvent.FAILED_COUNT: 0, + GdUnitEvent.SKIPPED: true, + GdUnitEvent.SKIPPED_COUNT: 1, + } + var report := GdUnitReport.new() \ + .create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped(test_case.skip_info())) + fire_event(GdUnitEvent.new().test_after(test_case.id(), test_case.test_name(), statistics, [report])) diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid new file mode 100644 index 0000000..a81b53e --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://cs71x2r4qmxgy diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd new file mode 100644 index 0000000..a556758 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd @@ -0,0 +1,56 @@ +## The fuzzed test case execution stage.[br] +class_name GdUnitTestCaseFuzzedTestStage +extends IGdUnitExecutionStage + +var _expression_runner := GdUnitExpressionRunner.new() + + +## Executes a test case with given fuzzers 'test_()' iterative.[br] +## It executes synchronized following stages[br] +## -> test_case() [br] +func _execute(context :GdUnitExecutionContext) -> void: + var test_suite := context.test_suite + var test_case := context.test_case + var fuzzers := create_fuzzers(test_suite, test_case) + + # guard on fuzzers + for fuzzer in fuzzers: + @warning_ignore("return_value_discarded") + GdUnitMemoryObserver.guard_instance(fuzzer) + + for iteration in test_case.iterations(): + @warning_ignore("redundant_await") + await test_suite.before_test() + await test_case.execute(fuzzers, iteration) + @warning_ignore("redundant_await") + await test_suite.after_test() + if test_case.is_interupted(): + break + # interrupt at first failure + var reports := context.reports() + if not reports.is_empty(): + var report :GdUnitReport = reports.pop_front() + reports.append(GdUnitReport.new() \ + .create(GdUnitReport.FAILURE, report.line_number(), GdAssertMessages.fuzzer_interuped(iteration, report.message()))) + break + + await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_CASE) + + # unguard on fuzzers + if not test_case.is_interupted(): + for fuzzer in fuzzers: + GdUnitMemoryObserver.unguard_instance(fuzzer) + + +func create_fuzzers(test_suite :GdUnitTestSuite, test_case :_TestCase) -> Array[Fuzzer]: + if not test_case.is_fuzzed(): + return Array() + test_case.generate_seed() + var fuzzers :Array[Fuzzer] = [] + for fuzzer_arg in test_case.fuzzer_arguments(): + @warning_ignore("unsafe_cast") + var fuzzer := _expression_runner.to_fuzzer(test_suite.get_script() as GDScript, fuzzer_arg.plain_value() as String) + fuzzer._iteration_index = 0 + fuzzer._iteration_limit = test_case.iterations() + fuzzers.append(fuzzer) + return fuzzers diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid new file mode 100644 index 0000000..7c0c371 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid @@ -0,0 +1 @@ +uid://dph5avjx3kvab diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd new file mode 100644 index 0000000..abf5c90 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd @@ -0,0 +1,53 @@ +## The test case execution stage.[br] +class_name GdUnitTestCaseSingleExecutionStage +extends IGdUnitExecutionStage + + +var _stage_before :IGdUnitExecutionStage = GdUnitTestCaseBeforeStage.new() +var _stage_after :IGdUnitExecutionStage = GdUnitTestCaseAfterStage.new() +var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseSingleTestStage.new() + + +func _execute(context :GdUnitExecutionContext) -> void: + fire_event(GdUnitEvent.new().test_before(context.test_case.id())) + while context.retry_execution(): + var test_context := GdUnitExecutionContext.of(context) + await _stage_before.execute(test_context) + if not test_context.is_skipped(): + await _stage_test.execute(GdUnitExecutionContext.of(test_context)) + await _stage_after.execute(test_context) + if test_context.is_success() or test_context.is_skipped() or test_context.is_interupted(): + break + + await context.gc() + if context.is_skipped(): + fire_test_skipped(context) + else: + var reports: = context.collect_reports(true) + var statistics := context.calculate_statistics(reports) + fire_event(GdUnitEvent.new().test_after(context.test_case.id(), context.test_case.test_name(), statistics, reports)) + + +func set_debug_mode(debug_mode :bool = false) -> void: + super.set_debug_mode(debug_mode) + _stage_before.set_debug_mode(debug_mode) + _stage_after.set_debug_mode(debug_mode) + _stage_test.set_debug_mode(debug_mode) + + +func fire_test_skipped(context: GdUnitExecutionContext) -> void: + var test_case := context.test_case + var statistics := { + GdUnitEvent.ORPHAN_NODES: 0, + GdUnitEvent.ELAPSED_TIME: 0, + GdUnitEvent.WARNINGS: false, + GdUnitEvent.ERRORS: false, + GdUnitEvent.ERROR_COUNT: 0, + GdUnitEvent.FAILED: false, + GdUnitEvent.FAILED_COUNT: 0, + GdUnitEvent.SKIPPED: true, + GdUnitEvent.SKIPPED_COUNT: 1, + } + var report := GdUnitReport.new() \ + .create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped(test_case.skip_info())) + fire_event(GdUnitEvent.new().test_after(test_case.id(), test_case.test_name(), statistics, [report])) diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid new file mode 100644 index 0000000..be64a3c --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid @@ -0,0 +1 @@ +uid://l343hqy5e8wi diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd new file mode 100644 index 0000000..61cfedc --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd @@ -0,0 +1,10 @@ +## The single test case execution stage.[br] +class_name GdUnitTestCaseSingleTestStage +extends IGdUnitExecutionStage + + +## Executes a single test case 'test_()'.[br] +## It executes synchronized following stages[br] +## -> test_case() [br] +func _execute(context: GdUnitExecutionContext) -> void: + await context.test_case.execute() diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid new file mode 100644 index 0000000..9f0c857 --- /dev/null +++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid @@ -0,0 +1 @@ +uid://bmkj1hygvptas diff --git a/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd new file mode 100644 index 0000000..0f87ad1 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd @@ -0,0 +1,78 @@ +class_name GdUnitBaseReporterTestSessionHook +extends GdUnitTestSessionHook + + +var test_session: GdUnitTestSession: + get: + return test_session + set(value): + # disconnect first possible connected listener + if test_session != null: + test_session.test_event.disconnect(_on_test_event) + # add listening to current session + test_session = value + if test_session != null: + test_session.test_event.connect(_on_test_event) + + +var _report_summary: GdUnitReportSummary +var _reporter: GdUnitTestReporter +var _report_writer: GdUnitReportWriter +var _report_converter: Callable + +func _init(report_writer: GdUnitReportWriter, hook_name: String, hook_description: String, report_converter: Callable) -> void: + super(hook_name, hook_description) + _reporter = GdUnitTestReporter.new() + _report_writer = report_writer + _report_converter = report_converter + + +func startup(session: GdUnitTestSession) -> GdUnitResult: + test_session = session + _report_summary = GdUnitReportSummary.new(_report_converter) + _reporter.init_summary() + + return GdUnitResult.success() + + +func shutdown(session: GdUnitTestSession) -> GdUnitResult: + var report_path := _report_writer.write(session.report_path, _report_summary) + session.send_message("Open {0} Report at: file://{1}".format([_report_writer.output_format(), report_path])) + + return GdUnitResult.success() + + +func _on_test_event(event: GdUnitEvent) -> void: + match event.type(): + GdUnitEvent.TESTSUITE_BEFORE: + _reporter.init_statistics() + _report_summary.add_testsuite_report(event.resource_path(), event.suite_name(), event.total_count()) + GdUnitEvent.TESTSUITE_AFTER: + var statistics := _reporter.build_test_suite_statisitcs(event) + _report_summary.update_testsuite_counters( + event.resource_path(), + _reporter.error_count(statistics), + _reporter.failed_count(statistics), + _reporter.orphan_nodes(statistics), + _reporter.skipped_count(statistics), + _reporter.flaky_count(statistics), + event.elapsed_time()) + _report_summary.add_testsuite_reports( + event.resource_path(), + event.reports() + ) + GdUnitEvent.TESTCASE_BEFORE: + var test := test_session.find_test_by_id(event.guid()) + _report_summary.add_testcase(test.source_file, test.suite_name, test.display_name) + GdUnitEvent.TESTCASE_AFTER: + _reporter.add_test_statistics(event) + var test := test_session.find_test_by_id(event.guid()) + _report_summary.set_counters(test.source_file, + test.display_name, + event.error_count(), + event.failed_count(), + event.orphan_nodes(), + event.is_skipped(), + event.is_flaky(), + event.elapsed_time()) + _report_summary.add_reports(test.source_file, test.display_name, event.reports()) diff --git a/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid new file mode 100644 index 0000000..09f6bb9 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid @@ -0,0 +1 @@ +uid://cf4echv625uja diff --git a/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd new file mode 100644 index 0000000..4b8f390 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd @@ -0,0 +1,9 @@ +class_name GdUnitHtmlReporterTestSessionHook +extends GdUnitBaseReporterTestSessionHook + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + + +func _init() -> void: + super(GdUnitHtmlReportWriter.new(), "GdUnitHtmlTestReporter", "The Html test reporting hook.", GdUnitTools.richtext_normalize) + set_meta("SYSTEM_HOOK", true) diff --git a/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid new file mode 100644 index 0000000..51897c6 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid @@ -0,0 +1 @@ +uid://dxbb0padmuvaa diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd new file mode 100644 index 0000000..23850e4 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd @@ -0,0 +1,111 @@ +## @since GdUnit4 5.1.0 +## +## Base class for creating custom test session hooks in GdUnit4.[br] +## [br] +## [i]Test session hooks allow users to extend the GdUnit4 test framework by providing +## custom functionality that runs at specific points during the test execution lifecycle. +## This base class defines the interface that all test session hooks must implement.[/i] +## [br] +## [br] +## [b][u]Usage[/u][/b][br] +## 1. Create a new class that extends GdUnitTestSessionHook[br] +## 2. Override the required methods (startup, shutdown)[br] +## 3. Register your hook with the test engine (using the GdUnit4 settings dialog)[br] +## [br] +## [b][u]Example[/u][/b] +## [codeblock] +## class_name MyCustomTestHook +## extends GdUnitTestSessionHook +## +## func _init(): +## super("MyHook", "This is a description") +## +## func startup(session: GdUnitTestSession) -> GdUnitResult: +## session.send_message("Custom hook initialized") +## # Initialize resources, setup test environment, etc. +## return GdUnitResult.success() +## +## func shutdown(session: GdUnitTestSession) -> GdUnitResult: +## session.send_message("Custom hook cleanup completed") +## # Cleanup resources, generate reports, etc. +## return GdUnitResult.success() +## [/codeblock] +## +## [b][u]Hook Lifecycle[/u][/b][br] +## 1. [i][b]Registration[/b][/i]: Hooks are registered with the test engine via settings dialog[br] +## 2. [i][b]Priority Sorting[/b][/i]: Hooks are sorted by priority[br] +## 3. [i][b]Startup[/b][/i]: startup() is called before test execution begins, if it returns an error is shown in the console[br] +## 4. [i][b]Test Execution[/b][/i]: Tests run normally (only if all hooks started successfully)[br] +## 5. [i][b]Shutdown[/b][/i]: shutdown() is called after all tests complete, regardless of startup success[br] +## [br] +## [b][u]Priority System[/u][/b][br] +## The priority system allows controlling the execution order of multiple hooks.[br] +## - The order can be changed in the GdUnit4 settings dialog.[br] +## - The priority of system hooks cannot be changed and they cannot be deleted.[br] +## [br] +## [b][u]Session Access[/u][/b][br] +## +## Both [i]startup()[/i] and [i]shutdown()[/i] methods receive a [GdUnitTestSession] parameter that provides:[br] +## - Access to test cases being executed[br] +## - Event emission capabilities for test progress tracking[br] +## - Message sending functionality for logging and communication[br] +class_name GdUnitTestSessionHook +extends RefCounted + + +## The display name of this hook. +var name: String: + get: + return name + + +## A detailed description of what this hook does. +var description: String: + get: + return description + + +## Initializes a new test session hook. +## +## [param _name] The display name for this hook +## [param _description] A detailed description of the hook's functionality +func _init(_name: String, _description: String) -> void: + self.name = _name + self.description = _description + + +## Called when the test session starts up, before any tests are executed.[br] +## [br] +## [color=yellow][i]This method should be overridden to implement custom initialization logic[/i][/color][br] +## [br] +## such as:[br] +## - Setting up test databases or external services[br] +## - Initializing mock objects or test fixtures[br] +## - Configuring logging or reporting systems[br] +## - Preparing the test environment[br] +## - Subscribing to test events via the session[br] +## [br] +## [param session] The test session instance providing access to test data and communication[br] +## [b]return:[/b] [code]GdUnitResult.success()[/code] if initialization succeeds, or [code]GdUnitResult.error("error")[/code] with +## an error message if initialization fails. +func startup(_session: GdUnitTestSession) -> GdUnitResult: + return GdUnitResult.error("%s:startup is not implemented" % get_script().resource_path) + + +## Called when the test session shuts down, after all tests have completed.[br] +## [br] +## [color=yellow][i]This method should be overridden to implement custom cleanup logic[/i][/color][br] +## [br] +## such as:[br] +## - Cleaning up test databases or external services[br] +## - Generating test reports or artifacts[br] +## - Releasing resources allocated during startup[br] +## - Performing final validation or assertions[br] +## - Processing collected test events and data[br] +## [br] +## [param session] The test session instance providing access to test results and communication[br] +## [b]return:[/b] [code]GdUnitResult.success()[/code] if cleanup succeeds, or [code]GdUnitResult.error("error")[/code] with +## an error message if cleanup fails. Cleanup errors are typically logged +## but don't prevent the test engine from shutting down. +func shutdown(_session: GdUnitTestSession) -> GdUnitResult: + return GdUnitResult.error("%s:shutdown is not implemented" % get_script().resource_path) diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid new file mode 100644 index 0000000..18cb571 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid @@ -0,0 +1 @@ +uid://b58kde043x254 diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd new file mode 100644 index 0000000..d2e7b80 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd @@ -0,0 +1,192 @@ +class_name GdUnitTestSessionHookService +extends Object + + +var enigne_hooks: Array[GdUnitTestSessionHook] = []: + get: + return enigne_hooks + set(value): + enigne_hooks.append(value) + + +var _save_settings: bool = false + + +static func instance() -> GdUnitTestSessionHookService: + return GdUnitSingleton.instance("GdUnitTestSessionHookService", func()->GdUnitTestSessionHookService: + GdUnitSignals.instance().gdunit_message.emit("Installing GdUnit4 session system hooks.") + var service := GdUnitTestSessionHookService.new() + # Register default system hooks here + service._save_settings = false + service.register(GdUnitHtmlReporterTestSessionHook.new()) + service.register(GdUnitXMLReporterTestSessionHook.new()) + service.load_hook_settings() + service._save_settings = true + return service + ) + + +static func contains_hook(current: GdUnitTestSessionHook, other: GdUnitTestSessionHook) -> bool: + return current.get_script().resource_path == other.get_script().resource_path + + +func find_custom(hook: GdUnitTestSessionHook) -> int: + for index in enigne_hooks.size(): + if contains_hook.call(enigne_hooks[index], hook): + return index + return -1 + + +func load_hook(hook_resourc_path: String) -> GdUnitResult: + if !FileAccess.file_exists(hook_resourc_path): + return GdUnitResult.error("The hook '%s' not exists." % hook_resourc_path) + var script: GDScript = load(hook_resourc_path) + if script.get_base_script() != GdUnitTestSessionHook: + return GdUnitResult.error("The hook '%s' must inhertit from 'GdUnitTestSessionHook'." % hook_resourc_path) + + return GdUnitResult.success(script.new()) + + +func enable_hook(hook: GdUnitTestSessionHook, enabled: bool) -> void: + _enable_hook(hook, enabled) + GdUnitSignals.instance().gdunit_message.emit("Session hook '{name}' {enabled}.".format({ + "name": hook.name, + "enabled": "enabled" if enabled else "disabled"}) + ) + save_hock_setttings() + + +func register(hook: GdUnitTestSessionHook, enabled: bool = true) -> GdUnitResult: + if find_custom(hook) != -1: + return GdUnitResult.error("A hook instance of '%s' is already registered." % hook.get_script().resource_path) + + _enable_hook(hook, enabled) + enigne_hooks.append(hook) + save_hock_setttings() + GdUnitSignals.instance().gdunit_message.emit("Session hook '%s' installed." % hook.name) + + return GdUnitResult.success() + + +func unregister(hook: GdUnitTestSessionHook) -> GdUnitResult: + var hook_index := find_custom(hook) + if hook_index == -1: + return GdUnitResult.error("The hook instance of '%s' is NOT registered." % hook.get_script().resource_path) + + enigne_hooks.remove_at(hook_index) + save_hock_setttings() + return GdUnitResult.success() + + +func move_before(hook: GdUnitTestSessionHook, before: GdUnitTestSessionHook) -> void: + var before_index := find_custom(before) + var hook_index := find_custom(hook) + + # Verify the hook to move is behind the hook to be moved + if before_index >= hook_index: + return + + enigne_hooks.remove_at(hook_index) + enigne_hooks.insert(before_index, hook) + save_hock_setttings() + + +func move_after(hook: GdUnitTestSessionHook, after: GdUnitTestSessionHook) -> void: + var after_index := find_custom(after) + var hook_index := find_custom(hook) + + # Verify the hook to move is before the hook to be moved + if after_index <= hook_index: + return + + enigne_hooks.remove_at(hook_index) + enigne_hooks.insert(after_index, hook) + save_hock_setttings() + + +func execute_startup(session: GdUnitTestSession) -> GdUnitResult: + return await execute("startup", session) + + +func execute_shutdown(session: GdUnitTestSession) -> GdUnitResult: + return await execute("shutdown", session, true) + + +func execute(hook_func: String, session: GdUnitTestSession, reverse := false) -> GdUnitResult: + var failed_hook_calls: Array[GdUnitResult] = [] + + for hook_index in enigne_hooks.size(): + var index := enigne_hooks.size()-hook_index-1 if reverse else hook_index + var hook: = enigne_hooks[index] + if not is_enabled(hook): + continue + if OS.is_stdout_verbose(): + GdUnitSignals.instance().gdunit_message.emit("Session hook '%s' > %s()" % [hook.name, hook_func]) + var result: GdUnitResult = await hook.call(hook_func, session) + if result == null: + failed_hook_calls.push_back(GdUnitResult.error("Result is null! Check '%s'" % hook.get_script().resource_path)) + elif result.is_error(): + failed_hook_calls.push_back(result) + + if failed_hook_calls.is_empty(): + return GdUnitResult.success() + + var errors := failed_hook_calls.map(func(result: GdUnitResult) -> String: + return "Hook call '%s' failed with error: '%s'" % [hook_func, result.error_message()] + ) + return GdUnitResult.error( "\n".join(errors)) + + +func save_hock_setttings() -> void: + if not _save_settings: + return + + var hooks_to_save: Dictionary[String, bool] = {} + for hook in enigne_hooks: + var enabled: bool = hook.get_meta("enabled") + hooks_to_save[hook.get_script().resource_path] = enabled + + GdUnitSettings.set_session_hooks(hooks_to_save) + + +func load_hook_settings() -> void: + var hooks_resource_paths := GdUnitSettings.get_session_hooks() + if hooks_resource_paths.is_empty(): + return + + for hock_path: String in hooks_resource_paths.keys(): + var enabled := hooks_resource_paths[hock_path] + + # Do not reinstall already installed hooks + var existing_hooks := enigne_hooks.filter(func(element: GdUnitTestSessionHook) -> bool: + return element.get_script().resource_path == hock_path + ) + var existing_hook: GdUnitTestSessionHook = null if existing_hooks.is_empty() else existing_hooks.front() + # Applay enabled settings + if existing_hook != null: + _enable_hook(existing_hook, enabled) + continue + + # Load additional hooks + var result := load_hook(hock_path) + if result.is_error(): + push_error(result.error_message()) + continue + + GdUnitSignals.instance().gdunit_message.emit("Installing GdUnit4 session hooks.") + var hook: GdUnitTestSessionHook = result.value() + + result = register(hook, enabled) + if result.is_error(): + push_error(result.error_message()) + continue + + +static func is_enabled(hook: GdUnitTestSessionHook) -> bool: + if hook.has_meta("enabled"): + return hook.get_meta("enabled") + return true + + +func _enable_hook(hook: GdUnitTestSessionHook, enabled: bool) -> void: + hook.set_meta("enabled", enabled) diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid new file mode 100644 index 0000000..833d285 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid @@ -0,0 +1 @@ +uid://utcdtkxp5p0a diff --git a/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd new file mode 100644 index 0000000..94caef5 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd @@ -0,0 +1,11 @@ +class_name GdUnitXMLReporterTestSessionHook +extends GdUnitBaseReporterTestSessionHook + + +func _init() -> void: + super(JUnitXmlReportWriter.new(), "GdUnitXMLTestReporter", "The JUnit XML test reporting hook.", convert_report_message) + set_meta("SYSTEM_HOOK", true) + + +func convert_report_message(value: String) -> String: + return value diff --git a/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid new file mode 100644 index 0000000..04c0427 --- /dev/null +++ b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid @@ -0,0 +1 @@ +uid://d205nqll2gi8g diff --git a/addons/gdUnit4/src/core/parameters/GdBuiltinConstants.gd b/addons/gdUnit4/src/core/parameters/GdBuiltinConstants.gd new file mode 100644 index 0000000..5b56eb7 --- /dev/null +++ b/addons/gdUnit4/src/core/parameters/GdBuiltinConstants.gd @@ -0,0 +1,76 @@ +class_name GdBuiltinConstants +extends RefCounted + + +## Built-in Variant types that expose named constants via static member access (e.g. `Vector2.ONE`, +## `Color.RED`). This gate is what keeps resolution silent: only tokens whose type is listed here are +## compiled, so arbitrary `Identifier.UPPER` matches (enum refs, text) never reach the parser. +## New builtin types are rare, so this list changes far less often than the constants themselves. +const CONSTANT_BEARING_TYPES: PackedStringArray = [ + "Vector2", "Vector2i", + "Vector3", "Vector3i", + "Vector4", "Vector4i", + "Color", + "Basis", "Quaternion", "Plane", + "Transform2D", "Transform3D", "Projection", +] + +const _RESOLVE_SCRIPT_TEMPLATE := """ +extends RefCounted + +func value() -> Variant: + return %s +""" + +static var _resolved_values: Dictionary[String, Variant] = {} +static var _unresolvable_tokens: Dictionary[String, bool] = {} +static var _variant_types: Dictionary[String, int] = {} + + +## Resolves a dotted constant token (e.g. `Vector2.ONE`) to its runtime value, compiling it once and +## caching the result process-wide. Returns true when the token is a known constant of a supported +## builtin type; its value is then available via [method value_of]. +static func is_known_constant(type_name: String, token: String) -> bool: + if _resolved_values.has(token): + return true + if _unresolvable_tokens.has(token) or not CONSTANT_BEARING_TYPES.has(type_name): + return false + + var script := GDScript.new() + script.source_code = _RESOLVE_SCRIPT_TEMPLATE % token + if script.reload() != OK: + _unresolvable_tokens[token] = true + return false + + @warning_ignore("unsafe_method_access") + _resolved_values[token] = script.new().call("value") + return true + + +static func value_of(token: String) -> Variant: + return _resolved_values[token] + + +static func to_ordinal_value(value: String) -> int: + if _variant_types.is_empty(): + _build_variant_type_mapping() + + return _variant_types.get(value, TYPE_NIL) + + +static func _build_variant_type_mapping() -> void: + for ordinal_value in range(TYPE_MAX): + var ordinal_name := type_string(ordinal_value) + var type_name := "TYPE" + + # Handle complex type names + if ordinal_name.contains("Packed") or ordinal_name.contains("StringName") or ordinal_name.contains("NodePath"): + for index in range(ordinal_name.length()): + var character := ordinal_name[index] + if character == character.to_upper() and not character.is_valid_int(): + type_name += "_" + type_name += character.to_upper() + else: + type_name = "TYPE_" + ordinal_name.to_upper() + + _variant_types[type_name] = ordinal_value diff --git a/addons/gdUnit4/src/core/parameters/GdBuiltinConstants.gd.uid b/addons/gdUnit4/src/core/parameters/GdBuiltinConstants.gd.uid new file mode 100644 index 0000000..12671da --- /dev/null +++ b/addons/gdUnit4/src/core/parameters/GdBuiltinConstants.gd.uid @@ -0,0 +1 @@ +uid://c7h78lhf5if54 diff --git a/addons/gdUnit4/src/core/parameters/GdCallableParameterSetResolver.gd b/addons/gdUnit4/src/core/parameters/GdCallableParameterSetResolver.gd new file mode 100644 index 0000000..c9985e6 --- /dev/null +++ b/addons/gdUnit4/src/core/parameters/GdCallableParameterSetResolver.gd @@ -0,0 +1,110 @@ +class_name GdCallableParameterSetResolver +extends GdParameterSetResolver + + +var _expression: String +var _parameters: Array[Array] + +var _func_call_regex := RegEx.create_from_string("^(\\w+)\\((.*)\\)$") +var _string_regex := RegEx.create_from_string("^(['\"])(.*?)\\1$") + + +func _init(instance: Node, expression: String, args: Array[GdFunctionArgument] = []) -> void: + super(args) + _expression = expression + _parameters = _compile(instance, expression) + + +func get_max_index() -> int: + return _parameters.size() + + +func get_parameters(_instance: Node, index: int) -> Array: + return _parameters[index] + + +func _compile(instance: Node, expression: String) -> Array[Array]: + var regex_result := _func_call_regex.search(expression) + if regex_result == null: + push_error("GdCallableParameterSetResolver: Cannot parse expression '%s'" % expression) + return [] + + var func_name := regex_result.get_string(1) + var args_str := regex_result.get_string(2).strip_edges() + + if not instance.has_method(func_name): + push_error("GdCallableParameterSetResolver: Method '%s' not found on instance." % func_name) + return [] + + var args: Array = [] if args_str.is_empty() else _parse_arguments(args_str) + var parameter_set: Array = instance.callv(func_name, args) + if parameter_set == null: + return [] + + for parameters: Array in parameter_set: + _finalize_parameter_set(parameters) + + # We want to use allways typed arrays + if not parameter_set.is_typed(): + parameter_set = Array(parameter_set, TYPE_ARRAY, "", null) + + return parameter_set + + +func _parse_arguments(args_str: String) -> Array: + var result: Array = [] + + args_str = args_str.strip_edges() + if args_str.is_empty(): + return result + + for raw: String in _split_argumens(args_str): + var value := raw.strip_edges() + var quoted_string_value := _string_regex.search(value) + if quoted_string_value != null: + result.append(quoted_string_value.get_string(2)) + continue + result.append(str_to_var(value)) + return result + + +static func _split_argumens(input: String) -> Array[String]: + var result: Array[String] = [] + var current := "" + var in_quote := false + var quote_char := "" + var i := 0 + + while i < input.length(): + var character := input[i] + + if character == "\\": + if i + 1 < input.length(): + current += input[i + 1] + i += 2 + continue + + if character == '"' or character == "'": + if not in_quote: + in_quote = true + quote_char = character + elif character == quote_char: + in_quote = false + quote_char = "" + + current += character + i += 1 + continue + + if character == "," and not in_quote: + result.append(current.strip_edges()) + current = "" + i += 1 + continue + + current += character + i += 1 + + if current != "": + result.append(current.strip_edges()) + return result diff --git a/addons/gdUnit4/src/core/parameters/GdCallableParameterSetResolver.gd.uid b/addons/gdUnit4/src/core/parameters/GdCallableParameterSetResolver.gd.uid new file mode 100644 index 0000000..6114447 --- /dev/null +++ b/addons/gdUnit4/src/core/parameters/GdCallableParameterSetResolver.gd.uid @@ -0,0 +1 @@ +uid://b262ff1yay3bo diff --git a/addons/gdUnit4/src/core/parameters/GdInlineParameterSetResolver.gd b/addons/gdUnit4/src/core/parameters/GdInlineParameterSetResolver.gd new file mode 100644 index 0000000..1bc8122 --- /dev/null +++ b/addons/gdUnit4/src/core/parameters/GdInlineParameterSetResolver.gd @@ -0,0 +1,247 @@ +class_name GdInlineParameterSetResolver +extends GdParameterSetResolver + + +const EXCLUDE_PROPERTIES_TO_COPY := [ + "script", + "type", + "Node", + "_import_path"] + + +const EXPRESSION_TEMPLATE := """ +extends '${clazz_path}' + +func __run_expression() -> Array: + return ${test_params} + +""" + +var _parameter_sets: PackedStringArray +var _preparsed_expressions: Array[Expression] = [] +var _bound_input_values: Array[Array] = [] +var _source_path := "" +var _function_name := "" + + +static var _native_class_mapping: Dictionary[String, Variant] = {} +static var _user_class_paths: Dictionary[String, String] = {} +static var _resolved_user_classes: Dictionary[String, Variant] = {} +static var _constant_token_regex: RegEx +static var _script_variant_type_regex: RegEx + + +func _init( + parameter_sets: PackedStringArray, + source_path: String, + function_name: String, + args: Array[GdFunctionArgument] = [] +) -> void: + super(args) + _parameter_sets = parameter_sets + _source_path = source_path + _function_name = function_name + + var bound_class_names := _scan_bound_class_names(_parameter_sets) + for index in _parameter_sets.size(): + _preparse_parameter_set(index, bound_class_names[index]) + + +func get_max_index() -> int: + return _parameter_sets.size() + + +func get_parameters(instance: Node, index: int) -> Array: + var expression := _preparsed_expressions[index] + if expression == null: + return _run_expression_via_script(instance, _parameter_sets[index]) + + var parameters: Variant = expression.execute(_bound_input_values[index], instance, false) + if expression.has_execute_failed(): + prints("Expression execute error:", expression.get_error_text()) + return _run_expression_via_script(instance, _parameter_sets[index]) + if not parameters is Array: + return _run_expression_via_script(instance, _parameter_sets[index]) + + @warning_ignore("unsafe_call_argument") + return _finalize_parameter_set(parameters) + + +static func _scan_bound_class_names(parameter_sets: PackedStringArray) -> Array[PackedStringArray]: + var bound_class_names: Array[PackedStringArray] = [] + for index in bound_class_names.resize(parameter_sets.size()): + bound_class_names[index] = PackedStringArray() + + var candidate_names := PackedStringArray() + candidate_names.append_array(_get_native_class_mapping().keys()) + candidate_names.append_array(_get_user_class_paths().keys()) + for clazz_name: String in candidate_names: + for index in parameter_sets.size(): + if parameter_sets[index].contains(clazz_name) and not bound_class_names[index].has(clazz_name): + bound_class_names[index].append(clazz_name) + return bound_class_names + + +func _preparse_parameter_set(index: int, bound_class_names: PackedStringArray) -> void: + var input_names := bound_class_names + var input_values: Array = [] + for clazz_name in bound_class_names: + input_values.append(_class_value_of(clazz_name)) + + var expression_source := _parameter_sets[index] + for regex_match in _get_constant_token_regex().search_all(expression_source): + var token := regex_match.get_string(0) + var type_name := regex_match.get_string(1) + var alias := token.replace(".", "__") + if bound_class_names.has(type_name) or input_names.has(alias): + continue + if GdBuiltinConstants.is_known_constant(type_name, token): + input_names.append(alias) + input_values.append(GdBuiltinConstants.value_of(token)) + expression_source = expression_source.replace(token, alias) + + # Fix variant type resolving + for regex_match in _get_script_variant_type_regex().search_all(expression_source): + var constant_name := regex_match.get_string(0) + input_names.append(constant_name) + input_values.append(GdBuiltinConstants.to_ordinal_value(constant_name)) + + var expression := Expression.new() + if expression.parse(expression_source, input_names) != OK: + _print_fallback_warning(_parameter_sets[index], expression.get_error_text(), _source_path, _function_name) + _preparsed_expressions.append(null) + else: + _preparsed_expressions.append(expression) + _bound_input_values.append(input_values) + + +static func _print_fallback_warning(parameter_set: String, error_text: String, source_path: String, function_name: String) -> void: + if parameter_set.contains("as Array"): + push_warning(""" + Avoid type-casting with `as Array` in parameter sets; use the typed `Array(...)` constructor instead. + test: '%s' (%s) + expression: '%s' + Falling back to slower script-based resolver. + """.dedent() % [function_name, source_path, parameter_set]) + return + + push_warning(""" + Parameter expression parsing failed (error: %s). + Falling back to slower script-based resolver. + Check for unsupported syntax or missing class bindings. + test: '%s' (%s) + expression: '%s' + """.dedent() % [error_text, function_name, source_path, parameter_set]) + + +static func _get_constant_token_regex() -> RegEx: + if _constant_token_regex == null: + _constant_token_regex = RegEx.new() + _constant_token_regex.compile("([A-Za-z_][A-Za-z0-9_]*)\\.([A-Z][A-Z0-9_]*)\\b") + return _constant_token_regex + + +static func _get_script_variant_type_regex() -> RegEx: + if _script_variant_type_regex == null: + _script_variant_type_regex = RegEx.new() + _script_variant_type_regex.compile("TYPE_[A-Z0-9_]*\\b") + return _script_variant_type_regex + + +# This is a fallback option to run the expression by kind of reflection +func _run_expression_via_script(instance: Node, expression: String) -> Array: + var source_script: GDScript = instance.get_script() + var script := GDScript.new() + script.source_code = EXPRESSION_TEMPLATE \ + .replace("${clazz_path}", source_script.resource_path) \ + .replace("${test_params}", expression) + var debug := false + if debug == true: + # enable these lines only for debugging + script.resource_path = GdUnitFileAccess.create_temp_dir("parameter_extract") + "/%sExpression.gd" % source_script.resource_path.get_file() + DirAccess.remove_absolute(script.resource_path) + ResourceSaver.save(script, script.resource_path) + var result := script.reload() + if result != OK: + prints("Extracting test parameters failed! Script loading error: %s" % error_string(result)) + return [] + + var expression_runner: Node = script.new() + copy_properties(instance, expression_runner) + var parameters: Array = expression_runner.call("__run_expression") + expression_runner.free() + return _finalize_parameter_set(parameters) + + +## Returns the shared class-name-to-instance map, building it once on first access. +static func _get_native_class_mapping() -> Dictionary[String, Variant]: + if _native_class_mapping.is_empty(): + _native_class_mapping = _build_native_class_mapping() + return _native_class_mapping + + +static func _class_value_of(clazz_name: String) -> Variant: + var native_classes := _get_native_class_mapping() + if native_classes.has(clazz_name): + return native_classes[clazz_name] + return _lazily_loaded_user_class(clazz_name) + + +static func _lazily_loaded_user_class(clazz_name: String) -> Variant: + if not _resolved_user_classes.has(clazz_name): + _resolved_user_classes[clazz_name] = load(_get_user_class_paths()[clazz_name]) + return _resolved_user_classes[clazz_name] + + +static func _get_user_class_paths() -> Dictionary[String, String]: + if _user_class_paths.is_empty(): + for entry in ProjectSettings.get_global_class_list(): + if entry["language"] == &"GDScript": + var clazz_name: String = entry["class"] + _user_class_paths[clazz_name] = entry["path"] + return _user_class_paths + + +## Builds the class-name-to-instance map by generating and executing a GDScript that +## returns a dictionary literal — the only way to obtain live class references from +## [ClassDB] names, since GDScript has no eval or direct class-by-name lookup. +static func _build_native_class_mapping() -> Dictionary[String, Variant]: + var source := """ + extends RefCounted + + func get_class_type_mappings() -> Dictionary[String, Variant]: + return { + """.dedent() + + for clazz_name in ClassDB.get_class_list(): + if ClassDB.class_get_api_type(clazz_name) != 0 or not ClassDB.can_instantiate(clazz_name): + continue + if clazz_name.is_valid_identifier(): + source += '\t\t"%s": %s,\n' % [clazz_name, clazz_name] + source += "\t}" + + var script := GDScript.new() + script.source_code = source + var err := script.reload() + if err != OK: + prints("Failed to build class:type mappings: %s" % error_string(err)) + return {} + + @warning_ignore("unsafe_method_access") + return script.new().get_class_type_mappings() + + +static func copy_properties(source: Object, dest: Object) -> void: + for property in source.get_property_list(): + var property_name :String = property["name"] + var property_value :Variant = source.get(property_name) + if EXCLUDE_PROPERTIES_TO_COPY.has(property_name): + continue + #if dest.get(property_name) == null: + # prints("|%s|" % property_name, source.get(property_name)) + + # check for invalid name property + if property_name == "name" and property_value == "": + dest.set(property_name, ""); + continue + dest.set(property_name, property_value) diff --git a/addons/gdUnit4/src/core/parameters/GdInlineParameterSetResolver.gd.uid b/addons/gdUnit4/src/core/parameters/GdInlineParameterSetResolver.gd.uid new file mode 100644 index 0000000..adcf2a4 --- /dev/null +++ b/addons/gdUnit4/src/core/parameters/GdInlineParameterSetResolver.gd.uid @@ -0,0 +1 @@ +uid://bhhemr1tkjkbd diff --git a/addons/gdUnit4/src/core/parameters/GdParameterSetResolver.gd b/addons/gdUnit4/src/core/parameters/GdParameterSetResolver.gd new file mode 100644 index 0000000..f460dd5 --- /dev/null +++ b/addons/gdUnit4/src/core/parameters/GdParameterSetResolver.gd @@ -0,0 +1,145 @@ +## Abstract base class for all parameterized test resolvers. +## Subclasses resolve the concrete parameter values for each test iteration from +## different sources (inline literals, callable, or a property reference). +## The base class pre-builds a [ParameterSpec] list from the test function's +## parameter descriptors and provides a shared [method validate] implementation. +@abstract class_name GdParameterSetResolver +extends RefCounted + +## Placeholder written into the [code]_test_parameters[/code] position of a resolved +## parameter array to prevent re-initialization of the test data on the next invocation. +const EMPTY_SET: Array[Array] = [] + + +## Describes a single test function parameter and the type contract it must satisfy. +class ParameterSpec: + ## Position of this parameter in the resolved parameter array. + var index: int + ## The GDScript type constant ([code]TYPE_*[/code]) the resolved value must match. + var type: int + ## Value type hint, for typed arrays or dictionaries + var value_type_hint: int + ## [code]true[/code] when this parameter is [code]_test_parameters[/code] — internal, not a user-supplied test input. + var is_parameter_set: bool + ## Human-readable representation used in validation error messages. + var declaration: String + + func _init(p_index: int, p_type: int, p_is_parameter_set: bool, p_declaration: String, p_value_type_hint: int) -> void: + index = p_index + type = p_type + value_type_hint = p_value_type_hint + is_parameter_set = p_is_parameter_set + declaration = p_declaration + + +## Comma-separated display string of the user-supplied parameter names. +var _parameter_names := "" +## Ordered specs for every parameter in the test function. +var _parameter_specs: Array[ParameterSpec] = [] + + +func _init(args: Array[GdFunctionArgument]) -> void: + _build_parameter_specs(args) + + +## Builds [member _parameter_specs] and [member _parameter_names] from the test function descriptors. +func _build_parameter_specs(args: Array[GdFunctionArgument]) -> void: + var parameter_names: PackedStringArray = [] + for param_index in args.size(): + var param: GdFunctionArgument = args[param_index] + var param_str := str(param) + var param_type := param.type() + if param.is_parameter_set(): + _parameter_specs.append(ParameterSpec.new(param_index, TYPE_ARRAY, true, param_str, param.type_hint())) + else: + parameter_names.append(param_str) + _parameter_specs.append(ParameterSpec.new(param_index, param_type, false, param_str, param.type_hint())) + _parameter_names = ",".join(parameter_names) + + +## Validates [param parameters] against the parameter specs. +## Returns an error result when the count or any type does not match the test function signature. +func validate(parameters: Array, index: int) -> GdUnitResult: + if _parameter_specs.is_empty() and parameters.is_empty(): + return GdUnitResult.success("ok") + + if parameters.size() != _parameter_specs.size(): + # Exclude the _test_parameters value — it is internal, not a user-supplied test input. + var test_values := parameters.slice(0, parameters.size() - 1) + return GdUnitResult.error(""" + The test data set at index (%d) does not match the expected test parameters: + test function: [color=snow]func test...(%s)[/color] + test input values: [color=snow]%s[/color] + """.dedent() % [index, _parameter_names, test_values]) + + for spec: ParameterSpec in _parameter_specs: + var parameter_value: Variant = parameters[spec.index] + var current_type := typeof(parameter_value) + var type := spec.type + if spec.is_parameter_set: + # _test_parameters must always carry an empty Array — anything else is a resolver bug. + var test_parameters: Array = parameter_value + if typeof(test_parameters) != TYPE_ARRAY or not test_parameters.is_empty(): + var msg := "GdParameterSetResolver: '%s' must provide an empty Array for the '_test_parameters' parameter but got '%s'." \ + + " This is unexpected — please report it as a bug." + push_error(msg % [get_class(), test_parameters]) + continue + + # Untyped or Variant parameters accept any value — skip the type check. + if type in [TYPE_NIL, GdObjects.TYPE_VARIANT]: + continue + # Object-typed parameters accept null. + if type == TYPE_OBJECT and current_type == TYPE_NIL: + continue + + if type != current_type: + return GdUnitResult.error(""" + The test data value does not match the expected input type! + input value: [color=snow]'%s', <%s>[/color] + expected parameter: [color=snow]%s[/color] + """.dedent() % [parameter_value, type_string(current_type), spec.declaration]) + + return GdUnitResult.success("ok") + + +## Appends [constant EMPTY_SET] to [param parameters] to prevent re-initialisation of the +## [code]_test_parameters[/code] default value on the next invocation. +## Duplicates [param parameters] first when it is read-only (e.g. a GDScript class constant). +## Also coerces untyped [Array] values to typed arrays +func _finalize_parameter_set(parameters: Array) -> Array: + # need an untyped copy, else appending EMPTY_SET below fails on a typed Array. + if parameters.is_read_only() or parameters.is_typed(): + parameters = Array(parameters, TYPE_NIL, "", null) + + # Convert to typed Array and Dictionary parameters if required + for spec: ParameterSpec in _parameter_specs: + if spec.value_type_hint == TYPE_NIL: + continue + if spec.index >= parameters.size(): + continue + var val: Variant = parameters[spec.index] + if spec.type == TYPE_ARRAY: + @warning_ignore("unsafe_cast") + if typeof(val) == TYPE_ARRAY and not (val as Array).is_typed(): + @warning_ignore("unsafe_call_argument") + parameters[spec.index] = Array(val, spec.value_type_hint, "", null) + + # TODO not fully implemented + if spec.type == TYPE_DICTIONARY: + @warning_ignore("unsafe_cast") + if (val as Dictionary).is_typed(): + @warning_ignore("unsafe_call_argument") + # Dictionary(base, key_type, key_class_name, key_script, value_type, value_class_name, value_script) + parameters[spec.index] = Dictionary(val, spec.value_type_hint, "", null, spec.value_type_hint, "", null) + + # prevent re-initialisation of the '_test_parameters' default value on the next invocation. + parameters.append(EMPTY_SET) + return parameters + + +## Returns the resolved parameter values for the given test iteration index. +@abstract func get_parameters(instance: Node, index: int) -> Array + + +## Returns the total number of test iterations this resolver provides. +@abstract func get_max_index() -> int diff --git a/addons/gdUnit4/src/core/parameters/GdParameterSetResolver.gd.uid b/addons/gdUnit4/src/core/parameters/GdParameterSetResolver.gd.uid new file mode 100644 index 0000000..e777a89 --- /dev/null +++ b/addons/gdUnit4/src/core/parameters/GdParameterSetResolver.gd.uid @@ -0,0 +1 @@ +uid://ertiy3ya7208 diff --git a/addons/gdUnit4/src/core/parameters/GdParameterSetResolverFactory.gd b/addons/gdUnit4/src/core/parameters/GdParameterSetResolverFactory.gd new file mode 100644 index 0000000..5eaba37 --- /dev/null +++ b/addons/gdUnit4/src/core/parameters/GdParameterSetResolverFactory.gd @@ -0,0 +1,22 @@ +## Factory that selects and constructs the correct [GdParameterSetResolver] for a +## parameterized test function based on how the [code]_test_parameters[/code] default +## value is expressed: inline array literals, a callable, or a property reference. +class_name GdParameterSetResolverFactory +extends RefCounted + + +## Returns the appropriate resolver for [param fd], or null when the function is not parameterized. +static func create(fd: GdFunctionDescriptor, instance: Node) -> GdParameterSetResolver: + if not fd.is_parameterized(): + return null + var parameter_set_argument := GdFunctionArgument.get_parameter_set(fd.args()) + var parameter_sets := parameter_set_argument.parameter_sets() + + if not parameter_sets.is_empty(): + return GdInlineParameterSetResolver.new(parameter_sets, fd.source_path(), fd.name(), fd.args()) + + # A parenthesis signals a callable expression, e.g. "my_provider()". + var expression: String = parameter_set_argument._default_value + if expression.contains("("): + return GdCallableParameterSetResolver.new(instance, expression, fd.args()) + return GdPropertyParameterSetResolver.new(instance, expression, fd.args()) diff --git a/addons/gdUnit4/src/core/parameters/GdParameterSetResolverFactory.gd.uid b/addons/gdUnit4/src/core/parameters/GdParameterSetResolverFactory.gd.uid new file mode 100644 index 0000000..b37fb5e --- /dev/null +++ b/addons/gdUnit4/src/core/parameters/GdParameterSetResolverFactory.gd.uid @@ -0,0 +1 @@ +uid://ch5cj54hptwnm diff --git a/addons/gdUnit4/src/core/parameters/GdPropertyParameterSetResolver.gd b/addons/gdUnit4/src/core/parameters/GdPropertyParameterSetResolver.gd new file mode 100644 index 0000000..c946052 --- /dev/null +++ b/addons/gdUnit4/src/core/parameters/GdPropertyParameterSetResolver.gd @@ -0,0 +1,40 @@ +class_name GdPropertyParameterSetResolver +extends GdParameterSetResolver + +var _parameters: Array[Array] +var _property_name: String + + +func _init(instance: Node, property_name: String, args: Array[GdFunctionArgument] = []) -> void: + super(args) + _parameters = _resolve_property(instance, property_name) + _property_name = property_name + + +func get_max_index() -> int: + return _parameters.size() + + +func get_parameters(instance: Node, index: int) -> Array: + return _resolve_property(instance, _property_name)[index] + + +func _resolve_property(instance: Node, property_name: String) -> Array[Array]: + var result: Variant = instance.get(property_name) + + if result == null: + prints("The property `%s` do not exists." % property_name) + return [] + if not result is Array: + prints("The property `%s` must be an Array" % property_name) + return [] + + var parameter_set: Array = result + parameter_set = parameter_set.duplicate(true) + for parameters: Array in parameter_set: + _finalize_parameter_set(parameters) + + # We want to use allways typed arrays + if not parameter_set.is_typed(): + return Array(parameter_set, TYPE_ARRAY, "", null) + return parameter_set diff --git a/addons/gdUnit4/src/core/parameters/GdPropertyParameterSetResolver.gd.uid b/addons/gdUnit4/src/core/parameters/GdPropertyParameterSetResolver.gd.uid new file mode 100644 index 0000000..15d6597 --- /dev/null +++ b/addons/gdUnit4/src/core/parameters/GdPropertyParameterSetResolver.gd.uid @@ -0,0 +1 @@ +uid://sm4kha2tx7wj diff --git a/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd new file mode 100644 index 0000000..fc83742 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd @@ -0,0 +1,25 @@ +class_name GdClassDescriptor +extends RefCounted + + +var _name :String +var _is_inner_class :bool +var _functions :Array[GdFunctionDescriptor] + + +func _init(p_name :String, p_is_inner_class :bool, p_functions :Array[GdFunctionDescriptor]) -> void: + _name = p_name + _is_inner_class = p_is_inner_class + _functions = p_functions + + +func name() -> String: + return _name + + +func is_inner_class() -> bool: + return _is_inner_class + + +func functions() -> Array[GdFunctionDescriptor]: + return _functions diff --git a/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid new file mode 100644 index 0000000..ff7a650 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid @@ -0,0 +1 @@ +uid://dcn3qlhnjpnbh diff --git a/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd new file mode 100644 index 0000000..f1b2244 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd @@ -0,0 +1,290 @@ +# holds all decodings for default values +class_name GdDefaultValueDecoder +extends GdUnitSingleton + + +@warning_ignore("unused_parameter") +var _decoders := { + TYPE_NIL: func(value :Variant) -> String: return "null", + TYPE_STRING: func(value :Variant) -> String: return '"%s"' % value, + TYPE_STRING_NAME: _on_type_StringName, + TYPE_BOOL: func(value :Variant) -> String: return str(value).to_lower(), + TYPE_FLOAT: func(value :Variant) -> String: return '%f' % value, + TYPE_COLOR: _on_type_Color, + TYPE_ARRAY: _on_type_Array.bind(TYPE_ARRAY), + TYPE_PACKED_BYTE_ARRAY: _on_type_Array.bind(TYPE_PACKED_BYTE_ARRAY), + TYPE_PACKED_STRING_ARRAY: _on_type_Array.bind(TYPE_PACKED_STRING_ARRAY), + TYPE_PACKED_FLOAT32_ARRAY: _on_type_Array.bind(TYPE_PACKED_FLOAT32_ARRAY), + TYPE_PACKED_FLOAT64_ARRAY: _on_type_Array.bind(TYPE_PACKED_FLOAT64_ARRAY), + TYPE_PACKED_INT32_ARRAY: _on_type_Array.bind(TYPE_PACKED_INT32_ARRAY), + TYPE_PACKED_INT64_ARRAY: _on_type_Array.bind(TYPE_PACKED_INT64_ARRAY), + TYPE_PACKED_COLOR_ARRAY: _on_type_Array.bind(TYPE_PACKED_COLOR_ARRAY), + TYPE_PACKED_VECTOR2_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR2_ARRAY), + TYPE_PACKED_VECTOR3_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR3_ARRAY), + TYPE_PACKED_VECTOR4_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR4_ARRAY), + TYPE_DICTIONARY: _on_type_Dictionary, + TYPE_RID: _on_type_RID, + TYPE_NODE_PATH: _on_type_NodePath, + TYPE_VECTOR2: _on_type_Vector.bind(TYPE_VECTOR2), + TYPE_VECTOR2I: _on_type_Vector.bind(TYPE_VECTOR2I), + TYPE_VECTOR3: _on_type_Vector.bind(TYPE_VECTOR3), + TYPE_VECTOR3I: _on_type_Vector.bind(TYPE_VECTOR3I), + TYPE_VECTOR4: _on_type_Vector.bind(TYPE_VECTOR4), + TYPE_VECTOR4I: _on_type_Vector.bind(TYPE_VECTOR4I), + TYPE_RECT2: _on_type_Rect2, + TYPE_RECT2I: _on_type_Rect2i, + TYPE_PLANE: _on_type_Plane, + TYPE_QUATERNION: _on_type_Quaternion, + TYPE_AABB: _on_type_AABB, + TYPE_BASIS: _on_type_Basis, + TYPE_CALLABLE: _on_type_Callable, + TYPE_SIGNAL: _on_type_Signal, + TYPE_TRANSFORM2D: _on_type_Transform2D, + TYPE_TRANSFORM3D: _on_type_Transform3D, + TYPE_PROJECTION: _on_type_Projection, + TYPE_OBJECT: _on_type_Object +} + +static func _regex(pattern: String) -> RegEx: + var regex := RegEx.new() + var err := regex.compile(pattern) + if err != OK: + push_error("error '%s' checked pattern '%s'" % [err, pattern]) + return null + return regex + + +func get_decoder(type: int) -> Callable: + return _decoders.get(type, func(value :Variant) -> String: return '%s' % value) + + +func _on_type_StringName(value: StringName) -> String: + if value.is_empty(): + return 'StringName()' + return 'StringName("%s")' % value + + +func _on_type_Object(value: Variant, _type: int) -> String: + return str(value) + + +func _on_type_Color(color: Color) -> String: + if color == Color.BLACK: + return "Color()" + return "Color%s" % color + + +func _on_type_NodePath(path: NodePath) -> String: + if path.is_empty(): + return 'NodePath()' + return 'NodePath("%s")' % path + + +func _on_type_Callable(_cb: Callable) -> String: + return 'Callable()' + + +func _on_type_Signal(_s: Signal) -> String: + return 'Signal()' + + +func _on_type_Dictionary(dict: Dictionary) -> String: + if dict.is_empty(): + return '{}' + return str(dict) + + +func _on_type_Array(value: Variant, type: int) -> String: + match type: + TYPE_ARRAY: + return str(value) + + TYPE_PACKED_COLOR_ARRAY: + var colors := PackedStringArray() + for color: Color in value: + @warning_ignore("return_value_discarded") + colors.append(_on_type_Color(color)) + if colors.is_empty(): + return "PackedColorArray()" + return "PackedColorArray([%s])" % ", ".join(colors) + + TYPE_PACKED_VECTOR2_ARRAY: + var vectors := PackedStringArray() + for vector: Vector2 in value: + @warning_ignore("return_value_discarded") + vectors.append(_on_type_Vector(vector, TYPE_VECTOR2)) + if vectors.is_empty(): + return "PackedVector2Array()" + return "PackedVector2Array([%s])" % ", ".join(vectors) + + TYPE_PACKED_VECTOR3_ARRAY: + var vectors := PackedStringArray() + for vector: Vector3 in value: + @warning_ignore("return_value_discarded") + vectors.append(_on_type_Vector(vector, TYPE_VECTOR3)) + if vectors.is_empty(): + return "PackedVector3Array()" + return "PackedVector3Array([%s])" % ", ".join(vectors) + + TYPE_PACKED_VECTOR4_ARRAY: + var vectors := PackedStringArray() + for vector: Vector4 in value: + @warning_ignore("return_value_discarded") + vectors.append(_on_type_Vector(vector, TYPE_VECTOR4)) + if vectors.is_empty(): + return "PackedVector4Array()" + return "PackedVector4Array([%s])" % ", ".join(vectors) + + TYPE_PACKED_STRING_ARRAY: + var values := PackedStringArray() + for v: String in value: + @warning_ignore("return_value_discarded") + values.append('"%s"' % v) + if values.is_empty(): + return "PackedStringArray()" + return "PackedStringArray([%s])" % ", ".join(values) + + TYPE_PACKED_BYTE_ARRAY,\ + TYPE_PACKED_FLOAT32_ARRAY,\ + TYPE_PACKED_FLOAT64_ARRAY,\ + TYPE_PACKED_INT32_ARRAY,\ + TYPE_PACKED_INT64_ARRAY: + var vectors := PackedStringArray() + for vector: Variant in value: + @warning_ignore("return_value_discarded") + vectors.append(str(vector)) + if vectors.is_empty(): + return GdObjects.type_as_string(type) + "()" + return "%s([%s])" % [GdObjects.type_as_string(type), ", ".join(vectors)] + return "unknown array type %d" % type + + +func _on_type_Vector(value: Variant, type: int) -> String: + + if typeof(value) != type: + push_error("Internal Error: type missmatch detected for value '%s', expects type %s" % [value, type_string(type)]) + return "" + + match type: + TYPE_VECTOR2: + if value == Vector2(): + return "Vector2()" + return "Vector2%s" % value + TYPE_VECTOR2I: + if value == Vector2i(): + return "Vector2i()" + return "Vector2i%s" % value + TYPE_VECTOR3: + if value == Vector3(): + return "Vector3()" + return "Vector3%s" % value + TYPE_VECTOR3I: + if value == Vector3i(): + return "Vector3i()" + return "Vector3i%s" % value + TYPE_VECTOR4: + if value == Vector4(): + return "Vector4()" + return "Vector4%s" % value + TYPE_VECTOR4I: + if value == Vector4i(): + return "Vector4i()" + return "Vector4i%s" % value + return "unknown vector type %d" % type + + +func _on_type_Transform2D(transform: Transform2D) -> String: + if transform == Transform2D(): + return "Transform2D()" + return "Transform2D(Vector2%s, Vector2%s, Vector2%s)" % [transform.x, transform.y, transform.origin] + + +func _on_type_Transform3D(transform: Transform3D) -> String: + if transform == Transform3D(): + return "Transform3D()" + return "Transform3D(Vector3%s, Vector3%s, Vector3%s, Vector3%s)" % [transform.basis.x, transform.basis.y, transform.basis.z, transform.origin] + + +func _on_type_Projection(projection: Projection) -> String: + return "Projection(Vector4%s, Vector4%s, Vector4%s, Vector4%s)" % [projection.x, projection.y, projection.z, projection.w] + + +@warning_ignore("unused_parameter") +func _on_type_RID(value: RID) -> String: + return "RID()" + + +func _on_type_Rect2(rect: Rect2) -> String: + if rect == Rect2(): + return "Rect2()" + return "Rect2(Vector2%s, Vector2%s)" % [rect.position, rect.size] + + +func _on_type_Rect2i(rect: Variant) -> String: + if rect == Rect2i(): + return "Rect2i()" + return "Rect2i(Vector2i%s, Vector2i%s)" % [rect.position, rect.size] + + +func _on_type_Plane(plane: Plane) -> String: + if plane == Plane(): + return "Plane()" + return "Plane(%d, %d, %d, %d)" % [plane.x, plane.y, plane.z, plane.d] + + +func _on_type_Quaternion(quaternion: Quaternion) -> String: + if quaternion == Quaternion(): + return "Quaternion()" + return "Quaternion(%d, %d, %d, %d)" % [quaternion.x, quaternion.y, quaternion.z, quaternion.w] + + +func _on_type_AABB(aabb: AABB) -> String: + if aabb == AABB(): + return "AABB()" + return "AABB(Vector3%s, Vector3%s)" % [aabb.position, aabb.size] + + +func _on_type_Basis(basis: Basis) -> String: + if basis == Basis(): + return "Basis()" + return "Basis(Vector3%s, Vector3%s, Vector3%s)" % [basis.x, basis.y, basis.z] + + +static func decode(value: Variant) -> String: + var type := typeof(value) + @warning_ignore("unsafe_cast") + if GdArrayTools.is_type_array(type) and (value as Array).is_empty(): + return "" + # For Variant types we need to determine the original type + if type == GdObjects.TYPE_VARIANT: + type = typeof(value) + var decoder := _get_value_decoder(type) + if decoder == null: + push_error("No value decoder registered for type '%d'! Please open a Bug issue at 'https://github.com/MikeSchulze/gdUnit4/issues/new/choose'." % type) + return "null" + if type == TYPE_OBJECT: + return decoder.call(value, type) + return decoder.call(value) + + +static func decode_typed(type: int, value: Variant) -> String: + if value == null: + return "null" + # For Variant types we need to determine the original type + if type == GdObjects.TYPE_VARIANT: + type = typeof(value) + var decoder := _get_value_decoder(type) + if decoder == null: + push_error("No value decoder registered for type '%d'! Please open a Bug issue at 'https://github.com/MikeSchulze/gdUnit4/issues/new/choose'." % type) + return "null" + if type == TYPE_OBJECT: + return decoder.call(value, type) + return decoder.call(value) + + +static func _get_value_decoder(type: int) -> Callable: + var decoder: GdDefaultValueDecoder = instance( + "GdUnitDefaultValueDecoders", + func() -> GdDefaultValueDecoder: + return GdDefaultValueDecoder.new()) + return decoder.get_decoder(type) diff --git a/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid new file mode 100644 index 0000000..420bc7e --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid @@ -0,0 +1 @@ +uid://jgo7mg1d7t2s diff --git a/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd new file mode 100644 index 0000000..6d1fea4 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd @@ -0,0 +1,277 @@ +class_name GdFunctionArgument +extends RefCounted + + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const UNDEFINED: String = "<-NO_ARG->" +const ARG_PARAMETERIZED_TEST := ["test_parameters", "_test_parameters"] + +var _name: String +var _type: int +var _type_hint: int +var _default_value: Variant +var _parameter_sets: PackedStringArray = [] + +static var _fuzzer_regex: RegEx +static var _cleanup_leading_spaces := RegEx.create_from_string("(?m)^[ \t]+") +static var _fix_comma_space := RegEx.create_from_string(""", {0,}\t{0,}(?=(?:[^"]*"[^"]*")*[^"]*$)(?!\\s)""") + + +func _init(p_name: String, p_type: int, value: Variant = UNDEFINED, p_type_hint: int = TYPE_NIL) -> void: + _init_static_variables() + _name = p_name + _type = p_type + _type_hint = p_type_hint + if value != null and p_name in ARG_PARAMETERIZED_TEST: + _parameter_sets = _parse_parameters(str(value)) + _default_value = value + # is argument a fuzzer? + if _type == TYPE_OBJECT and _fuzzer_regex.search(_name): + _type = GdObjects.TYPE_FUZZER + + +func _init_static_variables() -> void: + if _fuzzer_regex == null: + _fuzzer_regex = GdUnitTools.to_regex("((?!(fuzzer_(seed|iterations)))fuzzer?\\w+)( ?+= ?+| ?+:= ?+| ?+:Fuzzer ?+= ?+|)") + _cleanup_leading_spaces = RegEx.create_from_string("(?m)^[ \t]+") + _fix_comma_space = RegEx.create_from_string(""", {0,}\t{0,}(?=(?:[^"]*"[^"]*")*[^"]*$)(?!\\s)""") + + +func name() -> String: + return _name + + +func default() -> Variant: + return type_convert(_default_value, _type) + + +func set_value(value: String) -> void: + # we onle need to apply default values for Objects, all others are provided by the method descriptor + if _type == GdObjects.TYPE_FUZZER: + _default_value = value + return + if _name in ARG_PARAMETERIZED_TEST: + _parameter_sets = _parse_parameters(value) + _default_value = value + return + + if _type == TYPE_NIL or _type == GdObjects.TYPE_VARIANT: + _type = _extract_value_type(value) + if _type == GdObjects.TYPE_VARIANT and _default_value == null: + _default_value = value + if _default_value == null: + match _type: + TYPE_DICTIONARY: + _default_value = as_dictionary(value) + TYPE_ARRAY: + _default_value = as_array(value) + GdObjects.TYPE_FUZZER: + _default_value = value + _: + _default_value = str_to_var(value) + # if converting fails assign the original value without converting + if _default_value == null and value != null: + _default_value = value + #prints("set default_value: ", _default_value, "with type %d" % _type, " from original: '%s'" % value) + + +func _extract_value_type(value: String) -> int: + if value != UNDEFINED: + if _fuzzer_regex.search(_name): + return GdObjects.TYPE_FUZZER + if value.rfind(")") == value.length()-1: + return GdObjects.TYPE_FUNC + return _type + + +func value_as_string() -> String: + if has_default(): + return GdDefaultValueDecoder.decode_typed(_type, _default_value) + return "" + + +func plain_value() -> Variant: + return _default_value + + +func type() -> int: + return _type + + +func type_hint() -> int: + return _type_hint + + +func has_default() -> bool: + return not is_same(_default_value, UNDEFINED) + + +func is_typed_array() -> bool: + return _type == TYPE_ARRAY and _type_hint != TYPE_NIL + + +func is_parameter_set() -> bool: + return _name in ARG_PARAMETERIZED_TEST + + +func parameter_sets() -> PackedStringArray: + return _parameter_sets + + +static func get_parameter_set(parameters: Array[GdFunctionArgument]) -> GdFunctionArgument: + for current in parameters: + if current != null and current.is_parameter_set(): + return current + return null + + +func _to_string() -> String: + var s := _name + if _type != TYPE_NIL: + s += ": " + GdObjects.type_as_string(_type) + if _type_hint != TYPE_NIL: + s += "[%s]" % GdObjects.type_as_string(_type_hint) + if has_default(): + s += "=" + value_as_string() + return s + + +static func _parse_parameters(input: String) -> PackedStringArray: + if not input.contains("["): + return [] + + input = _cleanup_leading_spaces.sub(input, "", true) + input = input.strip_edges().trim_prefix("[").trim_suffix("]").trim_prefix("]") + var single_quote := false + var double_quote := false + var output := PackedStringArray() + var buf := input.to_utf8_buffer() + + if buf.size() == 0: + return output + + var work := PackedByteArray() + work.resize(buf.size()) + var wp := 0 + var array_depth := 0 + var func_call_depth := 0 + var after_comma := false + var escape_next := false + + for c: int in buf: + var in_string: bool = single_quote or double_quote + + # escaped character inside a string, e.g. `\"` or `\'`: copy through as-is and + # do not let it toggle the quote state + if escape_next: + work[wp] = c; wp += 1 + escape_next = false + continue + + # '\': mark the next character as escaped, only meaningful inside a quoted string + if c == 92 and in_string: + work[wp] = c; wp += 1 + escape_next = true + continue + + # ' ': ignore spaces between array elements + if c == 32: + if in_string: + work[wp] = c; wp += 1; after_comma = false + elif (array_depth > 0 or func_call_depth > 0) and not after_comma: + work[wp] = c; wp += 1 + + # '\n': strip newlines outside quoted strings, preserve inside + elif c == 10: + if in_string: + work[wp] = c; wp += 1 + + # ',': step over array element seperator ',' + elif c == 44: + if array_depth == 0 and func_call_depth == 0: + if wp > 0: + @warning_ignore("return_value_discarded") + output.append(work.slice(0, wp).get_string_from_utf8()) + wp = 0 + after_comma = false + else: + work[wp] = c; wp += 1 + if not in_string: + after_comma = true + + # '\'': + elif c == 39: + if not double_quote: + single_quote = not single_quote + if after_comma: + work[wp] = 32; wp += 1; after_comma = false + work[wp] = c; wp += 1 + + # '"': + elif c == 34: + if not single_quote: + double_quote = not double_quote + if after_comma: + work[wp] = 32; wp += 1; after_comma = false + work[wp] = c; wp += 1 + + # '[' + elif c == 91: + if not in_string: + array_depth += 1 + if after_comma: + work[wp] = 32; wp += 1; after_comma = false + work[wp] = c; wp += 1 + + # ']' + elif c == 93: + if not in_string: + array_depth -= 1 + after_comma = false + work[wp] = c; wp += 1 + + # '(' + elif c == 40: + if not in_string: + func_call_depth += 1 + if after_comma: + work[wp] = 32; wp += 1; after_comma = false + work[wp] = c; wp += 1 + + # ')' + elif c == 41: + if not in_string: + func_call_depth -= 1 + after_comma = false + work[wp] = c; wp += 1 + else: + if after_comma: + work[wp] = 32; wp += 1; after_comma = false + work[wp] = c; wp += 1 + + if wp > 0: + @warning_ignore("return_value_discarded") + output.append(work.slice(0, wp).get_string_from_utf8()) + return output + + +## value converters +func as_array(value: String) -> Array: + if value == "Array()" or value == "[]": + return [] + + if value.begins_with("Array("): + value = value.lstrip("Array(").rstrip(")") + if value.begins_with("["): + return str_to_var(value) + return [] + + +func as_dictionary(value: String) -> Dictionary: + if value == "Dictionary()": + return {} + if value.begins_with("Dictionary("): + value = value.lstrip("Dictionary(").rstrip(")") + if value.begins_with("{"): + return str_to_var(value) + return {} diff --git a/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid new file mode 100644 index 0000000..4b3856d --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid @@ -0,0 +1 @@ +uid://k2uknvd11d25 diff --git a/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd new file mode 100644 index 0000000..1db567d --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd @@ -0,0 +1,311 @@ +class_name GdFunctionDescriptor +extends RefCounted + +var _is_virtual: bool +var _is_static: bool +var _is_engine: bool +var _is_coroutine: bool +var _name: String +var _source_path: String +var _begin_line: int = -1 +var _end_line: int = -1 +var _return_type: int +var _return_class: String +var _args: Array[GdFunctionArgument] +var _varargs: Array[GdFunctionArgument] + + +func _init(p_name: String, + p_is_virtual: bool, + p_is_static: bool, + p_is_engine: bool, + p_return_type: int, + p_return_class: String, + p_args: Array[GdFunctionArgument], + p_varargs: Array[GdFunctionArgument] = []) -> void: + _name = p_name + _return_type = p_return_type + _return_class = p_return_class + _is_virtual = p_is_virtual + _is_static = p_is_static + _is_engine = p_is_engine + _is_coroutine = false + _args = p_args + _varargs = p_varargs + + +func with_return_class(clazz_name: String) -> GdFunctionDescriptor: + _return_class = clazz_name + return self + + +func name() -> String: + return _name + + +func source_path() -> String: + return _source_path + + +func begin_line() -> int: + return _begin_line + + +func end_line() -> int: + return _end_line + + +func is_virtual() -> bool: + return _is_virtual + + +func is_static() -> bool: + return _is_static + + +func is_engine() -> bool: + return _is_engine + + +func is_vararg() -> bool: + return not _varargs.is_empty() + + +func is_coroutine() -> bool: + return _is_coroutine + + +func is_parameterized() -> bool: + for current in _args: + var arg :GdFunctionArgument = current + if arg.name() in GdFunctionArgument.ARG_PARAMETERIZED_TEST: + return true + return false + + +func is_private() -> bool: + return name().begins_with("_") and not is_virtual() + + +func return_type() -> int: + return _return_type + + +func return_type_as_string() -> String: + if return_type() == TYPE_NIL: + return "void" + if (return_type() == TYPE_OBJECT or return_type() == GdObjects.TYPE_ENUM) and not _return_class.is_empty(): + return _return_class + return GdObjects.type_as_string(return_type()) + + +func set_argument_value(arg_name: String, value: String) -> void: + var argument: GdFunctionArgument = _args.filter(func(arg: GdFunctionArgument) -> bool: + return arg.name() == arg_name + ).front() + if argument != null: + argument.set_value(value) + + +func enrich_arguments(arguments: Array[Dictionary]) -> void: + for arg_index: int in arguments.size(): + var arg: Dictionary = arguments[arg_index] + if arg["type"] != GdObjects.TYPE_VARARG: + var arg_name: String = arg["name"] + var arg_value: String = arg["value"] + set_argument_value(arg_name, arg_value) + + +func enrich_file_info(p_source_path: String, p_begin_line: int, p_end_line: int) -> void: + _source_path = p_source_path + _begin_line = p_begin_line + _end_line = p_end_line + + +func args() -> Array[GdFunctionArgument]: + return _args + + +func varargs() -> Array[GdFunctionArgument]: + return _varargs + + +func typed_args() -> String: + var collect := PackedStringArray() + for arg in args(): + @warning_ignore("return_value_discarded") + collect.push_back(arg._to_string()) + for arg in varargs(): + @warning_ignore("return_value_discarded") + collect.push_back(arg._to_string()) + return ", ".join(collect) + + +func _to_string() -> String: + var fsignature := "virtual " if is_virtual() else "" + if _return_type == TYPE_NIL: + return fsignature + "[Line:(%d,%d)] func %s(%s):" % [begin_line(), end_line(), name(), typed_args()] + var func_template := fsignature + "[Line:(%d,%d)] func %s(%s) -> %s:" + if is_static(): + func_template= "[Line:(%d,%d)] static func %s(%s) -> %s:" + return func_template % [begin_line(), end_line(), name(), typed_args(), return_type_as_string()] + + +# extract function description given by Object.get_method_list() +static func extract_from(descriptor: Dictionary, is_engine_ := true) -> GdFunctionDescriptor: + var func_name: String = descriptor["name"] + var function_flags: int = descriptor["flags"] + var return_descriptor: Dictionary = descriptor["return"] + var clazz_name: String = return_descriptor["class_name"] + var is_virtual_: bool = function_flags & METHOD_FLAG_VIRTUAL + var is_static_: bool = function_flags & METHOD_FLAG_STATIC + var is_vararg_: bool = function_flags & METHOD_FLAG_VARARG + + var return_type_ := _extract_return_type(return_descriptor) + + # Add hotfix to handle Godot bug https://github.com/godotengine/godot/issues/122629 + if func_name == '_static_init': + return_type_ = GdObjects.TYPE_VOID + + return GdFunctionDescriptor.new( + func_name, + is_virtual_, + is_static_, + is_engine_, + return_type_, + clazz_name, + _extract_args(descriptor), + _build_varargs(is_vararg_) + ) + +# temporary exclude GlobalScope enums +const enum_fix := [ + "Side", + "Corner", + "Orientation", + "ClockDirection", + "HorizontalAlignment", + "VerticalAlignment", + "InlineAlignment", + "EulerOrder", + "Error", + "Key", + "MIDIMessage", + "MouseButton", + "MouseButtonMask", + "JoyButton", + "JoyAxis", + "PropertyHint", + "PropertyUsageFlags", + "MethodFlags", + "Variant.Type", + "Control.LayoutMode"] + + +static func _extract_return_type(return_info: Dictionary) -> int: + var type: int = return_info["type"] + var usage: int = return_info["usage"] + if usage & PROPERTY_USAGE_CLASS_IS_ENUM: + return GdObjects.TYPE_ENUM + if usage & PROPERTY_USAGE_NIL_IS_VARIANT: + return GdObjects.TYPE_VARIANT + if type == TYPE_NIL: + return GdObjects.TYPE_VOID + return type + + +static func _extract_args(descriptor: Dictionary) -> Array[GdFunctionArgument]: + var args_: Array[GdFunctionArgument] = [] + var arguments: Array = descriptor["args"] + var defaults: Array = descriptor["default_args"] + # iterate backwards because the default values are stored from right to left + while not arguments.is_empty(): + var arg: Dictionary = arguments.pop_back() + var arg_name := _argument_name(arg) + var arg_type := _argument_type(arg) + var arg_type_hint := _argument_hint(arg) + if arg_type == TYPE_DICTIONARY and _is_typed_dictionary(arg): + #TODO add the dict_types to `GdFunctionArgument` + # var dict_types := _argument_dictionary_types(arg) + push_warning("typed dictionaries properties not supported yet: " + str(arguments)) + + #var arg_class: StringName = arg["class_name"] + var default_value: Variant = GdFunctionArgument.UNDEFINED if defaults.is_empty() else defaults.pop_back() + args_.push_front(GdFunctionArgument.new(arg_name, arg_type, default_value, arg_type_hint)) + return args_ + + +static func _build_varargs(p_is_vararg: bool) -> Array[GdFunctionArgument]: + var varargs_: Array[GdFunctionArgument] = [] + if not p_is_vararg: + return varargs_ + varargs_.push_back(GdFunctionArgument.new("varargs", GdObjects.TYPE_VARARG, '')) + return varargs_ + + +static func _argument_name(arg: Dictionary) -> String: + return arg["name"] + + +static func _argument_type(arg: Dictionary) -> int: + var type: int = arg["type"] + var usage: int = arg["usage"] + + if type == TYPE_OBJECT: + if arg["class_name"] == "Node": + return GdObjects.TYPE_NODE + if arg["class_name"] == "Fuzzer": + return GdObjects.TYPE_FUZZER + + # if the argument untyped we need to scan the assignef value type + if type == TYPE_NIL and usage == PROPERTY_USAGE_NIL_IS_VARIANT: + return GdObjects.TYPE_VARIANT + return type + + +static func _is_typed_dictionary(arg: Dictionary) -> bool: + var hint: int = arg["hint"] + + return hint == PROPERTY_HINT_DICTIONARY_TYPE + + +static func _argument_hint(arg: Dictionary) -> int: + var hint: int = arg["hint"] + var hint_string: String = arg["hint_string"] + + match hint: + PROPERTY_HINT_ARRAY_TYPE: + return GdObjects.string_to_type(hint_string) + _: + return 0 + + +static func _argument_dictionary_types(arg: Dictionary) -> Array[int]: + var dict_types: Array[int] = [TYPE_NIL, TYPE_NIL] + var hint: int = arg["hint"] + var hint_string: String = arg["hint_string"] + + if hint != PROPERTY_HINT_DICTIONARY_TYPE: + return dict_types + + var index := 0 + var hint_strings := hint_string.split(";") + for type_as_string in hint_strings: + dict_types[index] = GdObjects.string_to_type(type_as_string) + index += 1 + + return dict_types + + +static func _argument_type_as_string(arg: Dictionary) -> String: + var type := _argument_type(arg) + match type: + TYPE_NIL: + return "" + TYPE_OBJECT: + var clazz_name: String = arg["class_name"] + if not clazz_name.is_empty(): + return clazz_name + return "" + _: + return GdObjects.type_as_string(type) diff --git a/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid new file mode 100644 index 0000000..fbfe979 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid @@ -0,0 +1 @@ +uid://cavgthncoskv2 diff --git a/addons/gdUnit4/src/core/parse/GdScriptParser.gd b/addons/gdUnit4/src/core/parse/GdScriptParser.gd new file mode 100644 index 0000000..b1432fb --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdScriptParser.gd @@ -0,0 +1,800 @@ +class_name GdScriptParser +extends RefCounted + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +const TYPE_VOID := GdObjects.TYPE_VOID +const TYPE_VARIANT := GdObjects.TYPE_VARIANT +const TYPE_VARARG := GdObjects.TYPE_VARARG +const TYPE_FUNC := GdObjects.TYPE_FUNC +const TYPE_FUZZER := GdObjects.TYPE_FUZZER +const TYPE_ENUM := GdObjects.TYPE_ENUM + + +var TOKEN_NOT_MATCH := Token.new("") +var TOKEN_SPACE := SkippableToken.new(" ") +var TOKEN_TABULATOR := SkippableToken.new("\t") +var TOKEN_NEW_LINE := SkippableToken.new("\n") +var TOKEN_COMMENT := SkippableToken.new("#") +var TOKEN_ANNOTATION := SkippableToken.new("@") +var TOKEN_CLASS_NAME := RegExToken.new("class_name", GdUnitTools.to_regex("(class_name)\\s+([\\w\\p{L}\\p{N}_]+) (extends[a-zA-Z]+:)|(class_name)\\s+([\\w\\p{L}\\p{N}_]+)"), 5) +var TOKEN_INNER_CLASS := TokenInnerClass.new("class", GdUnitTools.to_regex("(class)\\s+(\\w\\p{L}\\p{N}_]+) (extends[a-zA-Z]+:)|(class)\\s+([\\w\\p{L}\\p{N}_]+)"), 5) +var TOKEN_EXTENDS := RegExToken.new("extends", GdUnitTools.to_regex("extends\\s+")) +var TOKEN_ENUM := RegExToken.new("enum", GdUnitTools.to_regex("enum\\s+")) +var TOKEN_FUNCTION_STATIC_DECLARATION := RegExToken.new("static func", GdUnitTools.to_regex("^static\\s+func\\s+([\\w\\p{L}\\p{N}_]+)"), 1) +var TOKEN_FUNCTION_DECLARATION := RegExToken.new("func", GdUnitTools.to_regex("^func\\s+([\\w\\p{L}\\p{N}_]+)"), 1) +var TOKEN_FUNCTION := Token.new(".") +var TOKEN_FUNCTION_RETURN_TYPE := Token.new("->") +var TOKEN_FUNCTION_END := Token.new("):") +var TOKEN_ARGUMENT_ASIGNMENT := Token.new("=") +var TOKEN_ARGUMENT_TYPE_ASIGNMENT := Token.new(":=") +var TOKEN_ARGUMENT_FUZZER := FuzzerToken.new(GdUnitTools.to_regex("((?!(fuzzer_(seed|iterations)))fuzzer?\\w+)( ?+= ?+| ?+:= ?+| ?+:Fuzzer ?+= ?+|)")) +var TOKEN_ARGUMENT_TYPE := Token.new(":") +var TOKEN_ARGUMENT_VARIADIC := Token.new("...") +var TOKEN_ARGUMENT_SEPARATOR := Token.new(",") +var TOKEN_BRACKET_ROUND_OPEN := Token.new("(") +var TOKEN_BRACKET_ROUND_CLOSE := Token.new(")") +var TOKEN_BRACKET_SQUARE_OPEN := Token.new("[") +var TOKEN_BRACKET_SQUARE_CLOSE := Token.new("]") +var TOKEN_BRACKET_CURLY_OPEN := Token.new("{") +var TOKEN_BRACKET_CURLY_CLOSE := Token.new("}") +var TOKEN_BACKSLASH := Token.new("\\") + + +var OPERATOR_ADD := Operator.new("+") +var OPERATOR_SUB := Operator.new("-") +var OPERATOR_MUL := Operator.new("*") +var OPERATOR_DIV := Operator.new("/") +var OPERATOR_REMAINDER := Operator.new("%") + +var TOKENS :Array[Token] = [ + TOKEN_SPACE, + TOKEN_TABULATOR, + TOKEN_NEW_LINE, + TOKEN_BACKSLASH, + TOKEN_COMMENT, + TOKEN_ANNOTATION, + TOKEN_BRACKET_ROUND_OPEN, + TOKEN_BRACKET_ROUND_CLOSE, + TOKEN_BRACKET_SQUARE_OPEN, + TOKEN_BRACKET_SQUARE_CLOSE, + TOKEN_BRACKET_CURLY_OPEN, + TOKEN_BRACKET_CURLY_CLOSE, + TOKEN_CLASS_NAME, + TOKEN_INNER_CLASS, + TOKEN_EXTENDS, + TOKEN_ENUM, + TOKEN_FUNCTION_STATIC_DECLARATION, + TOKEN_FUNCTION_DECLARATION, + TOKEN_ARGUMENT_FUZZER, + TOKEN_ARGUMENT_TYPE_ASIGNMENT, + TOKEN_ARGUMENT_ASIGNMENT, + TOKEN_ARGUMENT_TYPE, + TOKEN_ARGUMENT_VARIADIC, + TOKEN_FUNCTION, + TOKEN_ARGUMENT_SEPARATOR, + TOKEN_FUNCTION_RETURN_TYPE, + OPERATOR_ADD, + OPERATOR_SUB, + OPERATOR_MUL, + OPERATOR_DIV, + OPERATOR_REMAINDER, +] + +var _regex_strip_comments := GdUnitTools.to_regex("^([^#\"']|'[^']*'|\"[^\"]*\")*\\K#.*") +var _scanned_inner_classes := PackedStringArray() +var _script_constants := {} +var _is_awaiting := GdUnitTools.to_regex("\\bawait\\s+(?![^\"]*\"[^\"]*$)(?!.*#.*await)") + + +static func to_unix_format(input :String) -> String: + return input.replace("\r\n", "\n") + + +class Token extends RefCounted: + var _token: String + var _consumed: int + var _is_operator: bool + + func _init(p_token: String, p_is_operator := false) -> void: + _token = p_token + _is_operator = p_is_operator + _consumed = p_token.length() + + func match(input: String, pos: int) -> bool: + return input.findn(_token, pos) == pos + + func value() -> Variant: + return _token + + func is_operator() -> bool: + return _is_operator + + func is_inner_class() -> bool: + return _token == "class" + + func is_variable() -> bool: + return false + + func is_token(token_name :String) -> bool: + return _token == token_name + + func is_skippable() -> bool: + return false + + func _to_string() -> String: + return "Token{" + _token + "}" + + +class Operator extends Token: + func _init(p_value: String) -> void: + super(p_value, true) + + func _to_string() -> String: + return "OperatorToken{%s}" % [_token] + + +# A skippable token, is just a placeholder like space or tabs +class SkippableToken extends Token: + + func _init(p_token: String) -> void: + super(p_token) + + func is_skippable() -> bool: + return true + + +# Token to parse function arguments +class Variable extends Token: + var _plain_value :String + var _typed_value :Variant + var _type :int = TYPE_NIL + + + func _init(p_value: String) -> void: + super(p_value) + _type = _scan_type(p_value) + _plain_value = p_value + _typed_value = _cast_to_type(p_value, _type) + + + func _scan_type(p_value: String) -> int: + if p_value.begins_with("\"") and p_value.ends_with("\""): + return TYPE_STRING + var type_ := GdObjects.string_to_type(p_value) + if type_ != TYPE_NIL: + return type_ + if p_value.is_valid_int(): + return TYPE_INT + if p_value.is_valid_float(): + return TYPE_FLOAT + if p_value.is_valid_hex_number(): + return TYPE_INT + return TYPE_OBJECT + + + func _cast_to_type(p_value :String, p_type: int) -> Variant: + match p_type: + TYPE_STRING: + return p_value#.substr(1, p_value.length() - 2) + TYPE_INT: + return p_value.to_int() + TYPE_FLOAT: + return p_value.to_float() + return p_value + + + func is_variable() -> bool: + return true + + + func type() -> int: + return _type + + + func value() -> Variant: + return _typed_value + + + func plain_value() -> String: + return _plain_value + + + func _to_string() -> String: + return "Variable{%s: %s : '%s'}" % [_plain_value, GdObjects.type_as_string(_type), _token] + + +class RegExToken extends Token: + var _regex: RegEx + var _extract_group_index: int + var _value := "" + + + func _init(token: String, regex: RegEx, extract_group_index: int = -1) -> void: + super(token, false) + _regex = regex + _extract_group_index = extract_group_index + + + func match(input: String, pos: int) -> bool: + var matching := _regex.search(input, pos) + if matching == null or pos != matching.get_start(): + return false + if _extract_group_index != -1: + _value = matching.get_string(_extract_group_index) + _consumed = matching.get_end() - matching.get_start() + return true + + + func value() -> String: + return _value + + +# Token to parse Fuzzers +class FuzzerToken extends RegExToken: + + + func _init(regex: RegEx) -> void: + super("fuzzer", regex, 1) + + + func name() -> String: + return value() + + + func type() -> int: + return GdObjects.TYPE_FUZZER + + + func _to_string() -> String: + return "FuzzerToken{%s: '%s'}" % [value(), _token] + + +class TokenInnerClass extends RegExToken: + var _content := PackedStringArray() + + + static func _strip_leading_spaces(input: String) -> String: + var characters := input.to_utf8_buffer() + while not characters.is_empty(): + if characters[0] != 0x20: + break + characters.remove_at(0) + return characters.get_string_from_utf8() + + + static func _consumed_bytes(row: String) -> int: + return row.replace(" ", "").replace(" ", "").length() + + + func _init(token: String, p_regex: RegEx, extract_group_index: int = -1) -> void: + super(token, p_regex, extract_group_index) + + + func is_class_name(clazz_name: String) -> bool: + return value() == clazz_name + + + func content() -> PackedStringArray: + return _content + + + @warning_ignore_start("return_value_discarded") + func parse(source_rows: PackedStringArray, offset: int) -> void: + # add class signature + _content.clear() + _content.append(source_rows[offset]) + # parse class content + for row_index in range(offset+1, source_rows.size()): + # scan until next non tab + var source_row := source_rows[row_index] + var row := TokenInnerClass._strip_leading_spaces(source_row) + if row.is_empty() or row.begins_with("\t") or row.begins_with("#"): + # fold all line to left by removing leading tabs and spaces + if source_row.begins_with("\t"): + source_row = source_row.trim_prefix("\t") + # refomat invalid empty lines + if source_row.dedent().is_empty(): + _content.append("") + else: + _content.append(source_row) + continue + break + _consumed += TokenInnerClass._consumed_bytes("".join(_content)) + @warning_ignore_restore("return_value_discarded") + + + func _to_string() -> String: + return "TokenInnerClass{%s}" % [value()] + + + +func get_token(input: String, current_index: int) -> Token: + for t in TOKENS: + if t.match(input, current_index): + return t + return TOKEN_NOT_MATCH + + +func next_token(input: String, current_index: int, ignore_tokens :Array[Token] = []) -> Token: + var token := TOKEN_NOT_MATCH + for t :Token in TOKENS.filter(func(t :Token) -> bool: return not ignore_tokens.has(t)): + + if t.match(input, current_index): + token = t + break + if token == OPERATOR_SUB: + token = tokenize_value(input, current_index, token) + if token == TOKEN_NOT_MATCH: + return tokenize_value(input, current_index, token, ignore_tokens.has(TOKEN_FUNCTION)) + return token + + +func tokenize_value(input: String, current: int, token: Token, ignore_dots := false) -> Token: + var next := 0 + var current_token := "" + # test for '--', '+-', '*-', '/-', '%-', or at least '-x' + var test_for_sign := (token == null or token.is_operator()) and input[current] == "-" + while current + next < len(input): + var character := input[current + next] as String + # if first charater a sign + # or allowend charset + # or is a float value + if (test_for_sign and next==0) \ + or is_allowed_character(character) \ + or (character == "." and (ignore_dots or current_token.is_valid_int())): + current_token += character + next += 1 + continue + break + if current_token != "": + return Variable.new(current_token) + return TOKEN_NOT_MATCH + + +# const ALLOWED_CHARACTERS := "0123456789_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"" +func is_allowed_character(input: String) -> bool: + var code_point := input.unicode_at(0) + # Unicode + if code_point > 127: + # This is a Unicode character (Chinese, Japanese, etc.) + return true + # ASCII digit 0-9 + if code_point >= 48 and code_point <= 57: + return true + # ASCII lowercase a-z + if code_point >= 97 and code_point <= 122: + return true + # ASCII uppercase A-Z + if code_point >= 65 and code_point <= 90: + return true + # underscore _ + if code_point == 95: + return true + # quotes '" + if code_point == 34 or code_point == 39: + return true + return false + + +func parse_return_token(input: String) -> Variable: + var index := input.rfind(TOKEN_FUNCTION_RETURN_TYPE._token) + if index == -1: + return TOKEN_NOT_MATCH + index += TOKEN_FUNCTION_RETURN_TYPE._consumed + # We scan for the return value exclusive '.' token because it could be referenced to a + # external or internal class e.g. 'func foo() -> InnerClass.Bar:' + var token := next_token(input, index, [TOKEN_FUNCTION]) + while !token.is_variable() and token != TOKEN_NOT_MATCH: + index += token._consumed + token = next_token(input, index, [TOKEN_FUNCTION]) + return token + + +func get_function_descriptors(script: GDScript, included_functions: PackedStringArray = []) -> Array[GdFunctionDescriptor]: + var fds: Array[GdFunctionDescriptor] = [] + for method_descriptor in script.get_script_method_list(): + var func_name: String = method_descriptor["name"] + if included_functions.is_empty() or func_name in included_functions: + # exclude type set/geters + if is_getter_or_setter(func_name): + continue + if not fds.any(func(fd: GdFunctionDescriptor) -> bool: return fd.name() == func_name): + fds.append(GdFunctionDescriptor.extract_from(method_descriptor, false)) + + # we need to enrich it by default arguments and line number by parsing the script + # the engine core functions has no valid methods to get this info + _prescan_script(script) + _enrich_function_descriptor(script, fds) + return fds + + +func is_getter_or_setter(func_name: String) -> bool: + return func_name.begins_with("@") and (func_name.ends_with("getter") or func_name.ends_with("setter")) + + +func _parse_function_arguments(input: String) -> Array[Dictionary]: + var arguments: Array[Dictionary] = [] + var current_index := 0 + var token: Token = null + var bracket := 0 + var in_function := false + + + while current_index < len(input): + token = next_token(input, current_index) + # fallback to not end in a endless loop + if token == TOKEN_NOT_MATCH: + var error : = """ + Parsing Error: Invalid token at pos %d found. + Please report this error! + source_code: + -------------------------------------------------------------- + %s + -------------------------------------------------------------- + """.dedent() % [current_index, input] + push_error(error) + current_index += 1 + continue + current_index += token._consumed + if token.is_skippable(): + continue + if token == TOKEN_BRACKET_ROUND_OPEN : + in_function = true + bracket += 1 + if token == TOKEN_BRACKET_ROUND_CLOSE: + bracket -= 1 + # if function end? + if in_function and bracket == 0: + return arguments + # is function + if token == TOKEN_FUNCTION_DECLARATION: + continue + + # is value argument + if in_function: + var arg_value := "" + var current_argument := { + "name" : "", + "value" : GdFunctionArgument.UNDEFINED, + "type" : TYPE_VARIANT + } + + # parse type and default value + while current_index < len(input): + token = next_token(input, current_index) + current_index += token._consumed + if token.is_skippable(): + continue + + if token.is_variable() && current_argument["name"] == "": + arguments.append(current_argument) + current_argument["name"] = (token as Variable).plain_value() + continue + + match token: + # is fuzzer argument + TOKEN_ARGUMENT_FUZZER: + arg_value = _parse_end_function(input.substr(current_index), true) + current_index += arg_value.length() + current_argument["name"] = (token as FuzzerToken).name() + current_argument["value"] = arg_value.lstrip(" ") + current_argument["type"] = TYPE_FUZZER + arguments.append(current_argument) + continue + + TOKEN_ARGUMENT_VARIADIC: + current_argument["type"] = TYPE_VARARG + + TOKEN_ARGUMENT_TYPE: + token = next_token(input, current_index) + if token == TOKEN_SPACE: + current_index += token._consumed + token = next_token(input, current_index) + current_index += token._consumed + if current_argument["type"] != TYPE_VARARG: + current_argument["type"] = GdObjects.string_to_type((token as Variable).plain_value()) + + TOKEN_ARGUMENT_TYPE_ASIGNMENT: + arg_value = _parse_end_function(input.substr(current_index), true) + current_index += arg_value.length() + current_argument["value"] = arg_value.lstrip(" ") + TOKEN_ARGUMENT_ASIGNMENT: + token = next_token(input, current_index) + arg_value = _parse_end_function(input.substr(current_index), true) + current_index += arg_value.length() + current_argument["value"] = arg_value.lstrip(" ") + + TOKEN_BRACKET_SQUARE_OPEN: + bracket += 1 + TOKEN_BRACKET_CURLY_OPEN: + bracket += 1 + TOKEN_BRACKET_ROUND_OPEN : + bracket += 1 + # if value a function? + if bracket > 1: + # complete the argument value + var func_begin := input.substr(current_index-TOKEN_BRACKET_ROUND_OPEN ._consumed) + var func_body := _parse_end_function(func_begin) + arg_value += func_body + # fix parse index to end of value + current_index += func_body.length() - TOKEN_BRACKET_ROUND_OPEN ._consumed - TOKEN_BRACKET_ROUND_CLOSE._consumed + TOKEN_BRACKET_SQUARE_CLOSE: + bracket -= 1 + TOKEN_BRACKET_CURLY_CLOSE: + bracket -= 1 + TOKEN_BRACKET_ROUND_CLOSE: + bracket -= 1 + # end of function + if bracket == 0: + break + TOKEN_ARGUMENT_SEPARATOR: + if bracket <= 1: + # next argument + current_argument = { + "name" : "", + "value" : GdFunctionArgument.UNDEFINED, + "type" : GdObjects.TYPE_VARIANT + } + continue + return arguments + + +## Finds the index of the next [param quote_char] in [param input] starting at [param start], +## skipping backslash-escaped characters (e.g. [code]\"[/code]) so escaped quotes do not end the string. +## Returns -1 when no unescaped quote is found. +func _find_unescaped_quote(input: String, quote_char: String, start: int) -> int: + var index := start + while index < input.length(): + var character := input[index] + if character == "\\": + index += 2 + continue + if character == quote_char: + return index + index += 1 + return -1 + + +func _parse_end_function(input: String, remove_trailing_char := false) -> String: + # find end of function + var current_index := 0 + var bracket_count := 0 + var in_array := 0 + var in_dict := 0 + var end_of_func := false + + while current_index < len(input) and not end_of_func: + var character := input[current_index] + # step over strings + if character == "'" : + current_index = _find_unescaped_quote(input, "'", current_index+1) + 1 + if current_index == 0: + push_error("Parsing error on '%s', can't evaluate end of string." % input) + return "" + continue + if character == '"' : + # test for string blocks + if input.find('"""', current_index) == current_index: + current_index = input.find('"""', current_index+3) + 3 + else: + current_index = _find_unescaped_quote(input, '"', current_index+1) + 1 + if current_index == 0: + push_error("Parsing error on '%s', can't evaluate end of string." % input) + return "" + continue + + match character: + # count if inside an array + "[": in_array += 1 + "]": in_array -= 1 + # count if inside an dictionary + "{": in_dict += 1 + "}": in_dict -= 1 + # count if inside a function + "(": bracket_count += 1 + ")": + bracket_count -= 1 + if bracket_count < 0 and in_array <= 0 and in_dict <= 0: + end_of_func = true + ",": + if bracket_count == 0 and in_array == 0 and in_dict <= 0: + end_of_func = true + current_index += 1 + if remove_trailing_char: + # check if the parsed value ends with comma or end of doubled breaked + # `,` or `())` + var trailing_char := input[current_index-1] + if trailing_char == ',' or (bracket_count < 0 and trailing_char == ')'): + return input.substr(0, current_index-1) + return input.substr(0, current_index) + + +func extract_inner_class(source_rows: PackedStringArray, clazz_name :String) -> PackedStringArray: + for row_index in source_rows.size(): + var input := source_rows[row_index] + var token := next_token(input, 0) + if token.is_inner_class(): + @warning_ignore("unsafe_method_access") + if token.is_class_name(clazz_name): + @warning_ignore("unsafe_method_access") + token.parse(source_rows, row_index) + @warning_ignore("unsafe_method_access") + return token.content() + return PackedStringArray() + + +func extract_func_signature(rows: PackedStringArray, index: int) -> String: + var signature := "" + + for rowIndex in range(index, rows.size()): + var row := rows[rowIndex] + row = _regex_strip_comments.sub(row, "").strip_edges(false) + if row.is_empty(): + continue + signature += row + "\n" + if is_func_end(row): + return signature.strip_edges() + push_error("Can't fully extract function signature of '%s'" % rows[index]) + return "" + + +func get_class_name(script :GDScript) -> String: + var source_code := GdScriptParser.to_unix_format(script.source_code) + var source_rows := source_code.split("\n") + + for index :int in min(10, source_rows.size()): + var input := source_rows[index] + var token := next_token(input, 0) + if token == TOKEN_CLASS_NAME: + return token.value() + # if no class_name found extract from file name + return GdObjects.to_pascal_case(script.resource_path.get_basename().get_file()) + + +func parse_func_name(input: String) -> String: + if TOKEN_FUNCTION_DECLARATION.match(input, 0): + return TOKEN_FUNCTION_DECLARATION.value() + if TOKEN_FUNCTION_STATIC_DECLARATION.match(input, 0): + return TOKEN_FUNCTION_STATIC_DECLARATION.value() + push_error("Can't extract function name from '%s'" % input) + return "" + + +## Enriches the function descriptor by line number and argument default values +## - enrich all function descriptors form current script up to all inherited scrips +func _enrich_function_descriptor(script: GDScript, fds: Array[GdFunctionDescriptor]) -> void: + var enriched_functions := {} # Use Dictionary for O(1) lookup instead of PackedStringArray + var script_to_scan := script + while script_to_scan != null: + # do not scan the test suite base class itself + if script_to_scan.resource_path == "res://addons/gdUnit4/src/GdUnitTestSuite.gd": + break + + var rows := script_to_scan.source_code.split("\n") + for rowIndex in rows.size(): + var input := rows[rowIndex] + # step over inner class functions + if input.begins_with("\t"): + continue + # skip comments and empty lines + if input.begins_with("#") or input.length() == 0: + continue + var token := next_token(input, 0) + if token != TOKEN_FUNCTION_STATIC_DECLARATION and token != TOKEN_FUNCTION_DECLARATION: + continue + + var function_name: String = token.value() + # Skip if already enriched (from parent class scan) + if enriched_functions.has(function_name): + continue + + # Find matching function descriptor + var fd: GdFunctionDescriptor = null + for candidate in fds: + if candidate.name() == function_name: + fd = candidate + break + if fd == null: + continue + # Mark as enriched + enriched_functions[function_name] = true + var func_signature := extract_func_signature(rows, rowIndex) + var func_arguments := _parse_function_arguments(func_signature) + var func_begin_line := rowIndex + 1 + var func_end_line := _parse_func_end_line(rows, func_begin_line) + # enrich missing default values + fd.enrich_arguments(func_arguments) + fd.enrich_file_info(script_to_scan.resource_path, func_begin_line, func_end_line) + fd._is_coroutine = is_func_coroutine(rows, rowIndex) + # enrich return class name if not set + if fd.return_type() == TYPE_OBJECT and fd._return_class in ["", "Resource", "RefCounted"]: + var var_token := parse_return_token(func_signature) + if var_token != TOKEN_NOT_MATCH and var_token.type() == TYPE_OBJECT: + fd._return_class = _patch_inner_class_names(var_token.plain_value(), "") + # if the script ihnerits we need to scan this also + script_to_scan = script_to_scan.get_base_script() + + +func _parse_func_end_line(rows: PackedStringArray, func_row_index: int) -> int: + var last_non_empty := func_row_index + for row_index in range(func_row_index, rows.size()): + var row := rows[row_index] + if row.begins_with("#") or row.is_empty(): + continue + var token := next_token(row, 0) + # scan until next function or annotation + if token in [TOKEN_ANNOTATION, TOKEN_FUNCTION_STATIC_DECLARATION, TOKEN_FUNCTION_DECLARATION]: + break + last_non_empty = row_index + return last_non_empty + 1 + + +func is_func_coroutine(rows :PackedStringArray, index :int) -> bool: + var is_coroutine := false + for rowIndex in range(index+1, rows.size()): + var input := rows[rowIndex].strip_edges() + if input.begins_with("#") or input.is_empty(): + continue + var token := next_token(input, 0) + # scan until next function + if token == TOKEN_FUNCTION_STATIC_DECLARATION or token == TOKEN_FUNCTION_DECLARATION: + break + + if _is_awaiting.search(input): + return true + return is_coroutine + + +func is_inner_class(clazz_path :PackedStringArray) -> bool: + return clazz_path.size() > 1 + + +func is_func_end(row :String) -> bool: + return row.strip_edges(false, true).ends_with(":") + + +func _patch_inner_class_names(clazz :String, clazz_name :String = "") -> String: + var inner_clazz_name := clazz.split(".")[0] + if _scanned_inner_classes.has(inner_clazz_name): + return inner_clazz_name + #var base_clazz := clazz_name.split(".")[0] + #return base_clazz + "." + clazz + if _script_constants.has(clazz): + return clazz_name + "." + clazz + return clazz + + +func _prescan_script(script: GDScript) -> void: + _script_constants = script.get_script_constant_map() + for key :String in _script_constants.keys(): + var value :Variant = _script_constants.get(key) + if value is GDScript: + @warning_ignore("return_value_discarded") + _scanned_inner_classes.append(key) + + +func parse(clazz_name :String, clazz_path :PackedStringArray) -> GdUnitResult: + if clazz_path.is_empty(): + return GdUnitResult.error("Invalid script path '%s'" % clazz_path) + var is_inner_class_ := is_inner_class(clazz_path) + var script :GDScript = load(clazz_path[0]) + _prescan_script(script) + + if is_inner_class_: + var inner_class_name := clazz_path[1] + if _scanned_inner_classes.has(inner_class_name): + # do load only on inner class source code and enrich the stored script instance + var source_code := _load_inner_class(script, inner_class_name) + script = _script_constants.get(inner_class_name) + script.source_code = source_code + var function_descriptors := get_function_descriptors(script) + var gd_class := GdClassDescriptor.new(clazz_name, is_inner_class_, function_descriptors) + return GdUnitResult.success(gd_class) + + +func _load_inner_class(script: GDScript, inner_clazz: String) -> String: + var source_rows := GdScriptParser.to_unix_format(script.source_code).split("\n") + # extract all inner class names + var inner_class_code := extract_inner_class(source_rows, inner_clazz) + return "\n".join(inner_class_code) diff --git a/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid b/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid new file mode 100644 index 0000000..9ee11a2 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid @@ -0,0 +1 @@ +uid://chaqay6lpklka diff --git a/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd new file mode 100644 index 0000000..fc08452 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd @@ -0,0 +1,75 @@ +class_name GdUnitExpressionRunner +extends RefCounted + +const CLASS_TEMPLATE = """ +class_name _ExpressionRunner extends '${clazz_path}' + +func __run_expression() -> Variant: + return $expression + +""" + +var constructor_args_regex := RegEx.create_from_string("new\\((?.*)\\)") + + +func execute(src_script: GDScript, value: Variant) -> Variant: + if typeof(value) != TYPE_STRING: + return value + + var expression: String = value + var parameter_map := src_script.get_script_constant_map() + for key: String in parameter_map.keys(): + var parameter_value: Variant = parameter_map[key] + # check we need to construct from inner class + # we need to use the original class instance from the script_constant_map otherwise we run into a runtime error + if expression.begins_with(key + ".new") and parameter_value is GDScript: + var object: GDScript = parameter_value + var args := build_constructor_arguments(parameter_map, expression.substr(expression.find("new"))) + if args.is_empty(): + return object.new() + return object.callv("new", args) + + var script := GDScript.new() + var resource_path := "res://addons/gdUnit4/src/Fuzzers.gd" if src_script.resource_path.is_empty() else src_script.resource_path + script.source_code = CLASS_TEMPLATE.dedent()\ + .replace("${clazz_path}", resource_path)\ + .replace("$expression", expression) + #script.take_over_path(resource_path) + @warning_ignore("return_value_discarded") + script.reload(true) + var runner: Object = script.new() + @warning_ignore("unsafe_method_access") + var result: Variant = runner.__run_expression() + if runner.has_method("free"): + runner.call_deferred("free") + return result + + +func build_constructor_arguments(parameter_map: Dictionary, expression: String) -> Array[Variant]: + var result := constructor_args_regex.search(expression) + var extracted_arguments := result.get_string("args").strip_edges() + if extracted_arguments.is_empty(): + return [] + var arguments :Array = extracted_arguments.split(",") + return arguments.map(func(argument: String) -> Variant: + var value := argument.strip_edges() + + # is argument an constant value + if parameter_map.has(value): + return parameter_map[value] + # is typed named value like Vector3.ONE + for type:int in GdObjects.TYPE_AS_STRING_MAPPINGS: + var type_as_string:String = GdObjects.TYPE_AS_STRING_MAPPINGS[type] + if value.begins_with(type_as_string): + return type_convert(value, type) + # is value a string + if value.begins_with("'") or value.begins_with('"'): + return value.trim_prefix("'").trim_suffix("'").trim_prefix('"').trim_suffix('"') + # fallback to default value converting + return str_to_var(value) + ) + + +func to_fuzzer(src_script: GDScript, expression: String) -> Fuzzer: + @warning_ignore("unsafe_cast") + return execute(src_script, expression) as Fuzzer diff --git a/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid new file mode 100644 index 0000000..e74ecf9 --- /dev/null +++ b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid @@ -0,0 +1 @@ +uid://cf5uv65e7uk83 diff --git a/addons/gdUnit4/src/core/report/GdUnitError.gd b/addons/gdUnit4/src/core/report/GdUnitError.gd new file mode 100644 index 0000000..569e013 --- /dev/null +++ b/addons/gdUnit4/src/core/report/GdUnitError.gd @@ -0,0 +1,12 @@ +class_name GdUnitError +extends RefCounted + +var _message: String +var _line_number: int +var _stack_trace: GdUnitStackTrace + + +func _init(message: String, line_number: int, stack_trace: GdUnitStackTrace) -> void: + _message = message + _line_number = line_number + _stack_trace = stack_trace diff --git a/addons/gdUnit4/src/core/report/GdUnitError.gd.uid b/addons/gdUnit4/src/core/report/GdUnitError.gd.uid new file mode 100644 index 0000000..8ac2d82 --- /dev/null +++ b/addons/gdUnit4/src/core/report/GdUnitError.gd.uid @@ -0,0 +1 @@ +uid://kaurs1f1gwus diff --git a/addons/gdUnit4/src/core/report/GdUnitReport.gd b/addons/gdUnit4/src/core/report/GdUnitReport.gd new file mode 100644 index 0000000..4e350e2 --- /dev/null +++ b/addons/gdUnit4/src/core/report/GdUnitReport.gd @@ -0,0 +1,107 @@ +class_name GdUnitReport +extends Resource + +# report type +enum { + SUCCESS, + WARN, + FAILURE, + ORPHAN, + TERMINATED, + INTERUPTED, + ABORT, + SKIPPED, +} + +var _type :int +var _line_number :int +var _message :String +var _current_value: Variant +var _error: GdUnitError + + +func from_error(p_type: int, error: GdUnitError) -> GdUnitReport: + _type = p_type + _line_number = error._line_number + _message = error._message + _error = error + return self + + +func create(p_type :int, p_line_number :int, p_message :String) -> GdUnitReport: + _type = p_type + _line_number = p_line_number + _message = p_message + return self + + +func with_current_value(value: Variant) -> GdUnitReport: + _current_value = value + return self + + +func type() -> int: + return _type + + +func line_number() -> int: + return _line_number + + +func message() -> String: + return _message + + +func is_skipped() -> bool: + return _type == SKIPPED + + +func is_warning() -> bool: + return _type == WARN + + +func is_failure() -> bool: + return _type == FAILURE + + +func is_error() -> bool: + return _type == TERMINATED or _type == INTERUPTED or _type == ABORT + + +func is_orphan() -> bool: + return _type == ORPHAN + + +func stack_trace() -> GdUnitStackTrace: + if _error == null: + return null + return _error._stack_trace + + +func _to_string() -> String: + if _line_number == -1: + return "[color=green]line [/color][color=aqua]:[/color] %s" % [_message] + return "[color=green]line [/color][color=aqua]%d:[/color] %s" % [_line_number, _message] + + +func serialize() -> Dictionary: + var serialized := { + "type" : _type, + "line_number" : _line_number, + "message" : _message + } + var trace := stack_trace() + if trace != null: + serialized["stack_trace"] = trace.serialize() + return serialized + + +func deserialize(serialized: Dictionary) -> GdUnitReport: + _type = serialized["type"] + _line_number = serialized["line_number"] + _message = serialized["message"] + if serialized.has("stack_trace"): + @warning_ignore("unsafe_cast") + var trace := GdUnitStackTrace.deserialize(serialized["stack_trace"] as String) + _error = GdUnitError.new(_message, _line_number, trace) + return self diff --git a/addons/gdUnit4/src/core/report/GdUnitReport.gd.uid b/addons/gdUnit4/src/core/report/GdUnitReport.gd.uid new file mode 100644 index 0000000..fcb4ee1 --- /dev/null +++ b/addons/gdUnit4/src/core/report/GdUnitReport.gd.uid @@ -0,0 +1 @@ +uid://djbd2so4hbhhl diff --git a/addons/gdUnit4/src/core/runners/GdUnitScriptErrorCollector.gd b/addons/gdUnit4/src/core/runners/GdUnitScriptErrorCollector.gd new file mode 100644 index 0000000..ef2a517 --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitScriptErrorCollector.gd @@ -0,0 +1,66 @@ +## Collects GDScript parse errors.[br] +## [br] +## Installed via [method OS.add_logger] so it intercepts [constant ErrorType.ERROR_TYPE_SCRIPT] +## entries without triggering Godot's interactive CLI debugger.[br] +## Used by [class GdUnitTestCIRunner] to detect broken user test scripts early +## and exit with a meaningful error instead of silently skipping them. +class_name GdUnitScriptErrorCollector +extends Logger + + +## Holds information about a single captured script error. +class ScriptError extends RefCounted: + var _message: String + var _source_file: String + var _source_line: int + + func _init(message: String, source_file: String, source_line: int) -> void: + _message = message + _source_file = source_file + _source_line = source_line + + func _to_string() -> String: + if _source_file.is_empty(): + return _message + return "%s\n\tat %s:%d" % [_message, _source_file, _source_line] + + +var _errors: Array[ScriptError] = [] + + +func _init() -> void: + OS.add_logger(self) + + +func _log_error( + _function: String, + source_file: String, + source_line: int, + message: String, + _rationale: String, + _editor_notify: bool, + error_type: int, + _script_backtraces: Array[ScriptBacktrace] + ) -> void: + if error_type != ErrorType.ERROR_TYPE_SCRIPT: + return + _errors.append(ScriptError.new(message, source_file, source_line)) + + +func _log_message(_message: String, _error: bool) -> void: + pass + + +## Returns true if any script errors were captured. +func has_errors() -> bool: + return not _errors.is_empty() + + +## Returns all captured script errors. +func errors() -> Array[ScriptError]: + return _errors + + +## Clears all captured errors. +func clear() -> void: + _errors.clear() diff --git a/addons/gdUnit4/src/core/runners/GdUnitScriptErrorCollector.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitScriptErrorCollector.gd.uid new file mode 100644 index 0000000..4ef7ee2 --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitScriptErrorCollector.gd.uid @@ -0,0 +1 @@ +uid://bcdhdcik8aojq diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd new file mode 100644 index 0000000..f525784 --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd @@ -0,0 +1,476 @@ +#warning-ignore-all:return_value_discarded +class_name GdUnitTestCIRunner +extends "res://addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd" +## Command line test runner implementation.[br] +## [br] +## This runner is designed for CI/CD pipelines and command line test execution.[br] +## Features:[br] +## - Command line options for test configuration[br] +## - HTML and JUnit report generation[br] +## - Console output with colored formatting[br] +## - Progress and error reporting[br] +## - Test history management[br] +## [br] +## Example usage:[br] +## [codeblock] +## # Run all tests in a directory +## runtest -a +## +## # Run specific test suite with ignored tests +## runtest -a -i +## [/codeblock] + +var _console := GdUnitCSIMessageWriter.new() +var _console_reporter: GdUnitConsoleTestReporter +var _headless_mode_ignore := false +var _runner_config_file := "" +var _debug_cmd_args := PackedStringArray() +var _included_tests := PackedStringArray() +var _excluded_tests := PackedStringArray() + +## Command line options configuration +var _cmd_options := CmdOptions.new([ + CmdOption.new( + "-a, --add", + "-a ", + "Adds the given test suite or directory to the execution pipeline.", + TYPE_STRING + ), + CmdOption.new( + "-i, --ignore", + "-i ", + "Adds the given test suite or test case to the ignore list.", + TYPE_STRING + ), + CmdOption.new( + "-c, --continue", + "", + """By default GdUnit will abort checked first test failure to be fail fast, + instead of stop after first failure you can use this option to run the complete test set.""".dedent() + ), + CmdOption.new( + "-conf, --config", + "-conf [testconfiguration.cfg]", + "Run all tests by given test configuration. Default is 'GdUnitRunner.cfg'", + TYPE_STRING, + true + ), + CmdOption.new( + "-help", "", + "Shows this help message." + ), + CmdOption.new("--help-advanced", "", + "Shows advanced options." + ) + ], + [ + # advanced options + CmdOption.new( + "-rd, --report-directory", + "-rd ", + "Specifies the output directory in which the reports are to be written. The default is res://reports/.", + TYPE_STRING, + true + ), + CmdOption.new( + "-rc, --report-count", + "-rc ", + "Specifies how many reports are saved before they are deleted. The default is %s." % str(GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT), + TYPE_INT, + true + ), + #CmdOption.new("--list-suites", "--list-suites [directory]", "Lists all test suites located in the given directory.", TYPE_STRING), + #CmdOption.new("--describe-suite", "--describe-suite ", "Shows the description of selected test suite.", TYPE_STRING), + CmdOption.new( + "--info", "", + "Shows the GdUnit version info" + ), + CmdOption.new( + "--selftest", "", + "Runs the GdUnit self test" + ), + CmdOption.new( + "--ignoreHeadlessMode", + "--ignoreHeadlessMode", + "By default, running GdUnit4 in headless mode is not allowed. You can switch off the headless mode check by set this property." + ), + ]) + + +func _init() -> void: + super() + + +func _ready() -> void: + super() + # stop checked first test failure to fail fast + _executor.fail_fast(true) + _console_reporter = GdUnitConsoleTestReporter.new(_console, true) + GdUnitSignals.instance().gdunit_message.connect(_on_send_message) + + +func _notification(what: int) -> void: + super(what) + + +func init_runner() -> void: + await init_gd_unit() + + +## Returns the exit code based on test results.[br] +## Maps test report status to process exit codes. +func get_exit_code() -> int: + return report_exit_code() + + +## Cleanup and quit the runner.[br] +## [br] +## [param code] The exit code to return. +func quit(code: int) -> void: + set_process(false) + await super(code) + get_tree().quit(code) + + +## Prints info message to console.[br] +## [br] +## [param message] The message to print.[br] +## [param color] Optional color for the message. +func console_info(message: String, color: Color = Color.WHITE) -> void: + _console.color(color).println_message(message) + + +## Prints error message to console.[br] +## [br] +## [param message] The error message to print. +func console_error(message: String) -> void: + _console.prints_error(message) + + +## Prints warning message to console.[br] +## [br] +## [param message] The warning message to print. +func console_warning(message: String) -> void: + _console.prints_warning(message) + + +## Sets the directory for test reports.[br] +## [br] +## [param path] The path where reports should be written. +func set_report_dir(path: String) -> void: + report_base_path = ProjectSettings.globalize_path(GdUnitFileAccess.make_qualified_path(path)) + console_info( + "Set write reports to %s" % report_base_path, + Color.DEEP_SKY_BLUE + ) + + +## Sets how many report files to keep.[br] +## [br] +## [param count] The number of reports to keep. +func set_report_count(count: String) -> void: + var report_count := count.to_int() + if report_count < 1: + console_error( + "Invalid report history count '%s' set back to default %d" + % [count, GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT] + ) + max_report_history = GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT + else: + console_info( + "Set report history count to %s" % count, + Color.DEEP_SKY_BLUE + ) + max_report_history = report_count + + +## Disables fail-fast mode to run all tests.[br] +## By default tests stop on first failure. +func disable_fail_fast() -> void: + console_info( + "Disabled fail fast!", + Color.DEEP_SKY_BLUE + ) + @warning_ignore("unsafe_method_access") + _executor.fail_fast(false) + + +func run_self_test() -> void: + console_info( + "Run GdUnit4 self tests.", + Color.DEEP_SKY_BLUE + ) + disable_fail_fast() + + + +## Shows GdUnit and Godot version information. +func show_version() -> void: + console_info( + "Godot %s" % Engine.get_version_info().get("string") as String, + Color.DARK_SALMON + ) + var config := ConfigFile.new() + config.load("addons/gdUnit4/plugin.cfg") + console_info( + "GdUnit4 %s" % config.get_value("plugin", "version") as String, + Color.DARK_SALMON + ) + quit(RETURN_SUCCESS) + + +## Ignores headless mode restrictions.[br] +## Allows tests to run in headless mode despite limitations. +func check_headless_mode() -> void: + _headless_mode_ignore = true + + +## Shows available command line options.[br] +## [br] +## [param show_advanced] Whether to show advanced options. +func show_options(show_advanced: bool = false) -> void: + console_info( + """ + Usage: + runtest -a + runtest -a -i + """.dedent(), + Color.DARK_SALMON + ) + console_info( + "-- Options ---------------------------------------------------------------------------------------", + Color.DARK_SALMON + ) + for option in _cmd_options.default_options(): + descripe_option(option) + if show_advanced: + console_info( + "-- Advanced options --------------------------------------------------------------------------", + Color.DARK_SALMON + ) + for option in _cmd_options.advanced_options(): + descripe_option(option) + + +## Describes a single command line option.[br] +## [br] +## [param cmd_option] The option to describe. +func descripe_option(cmd_option: CmdOption) -> void: + console_info( + " %-40s" % str(cmd_option.commands()), + Color.CORNFLOWER_BLUE + ) + console_info( + cmd_option.description(), + Color.LIGHT_GREEN + ) + if not cmd_option.help().is_empty(): + console_info( + "%-4s %s" % ["", cmd_option.help()], + Color.DARK_TURQUOISE + ) + console_info("") + + +## Loads test configuration from file.[br] +## [br] +## [param path] Path to the configuration file. +func load_test_config(path := GdUnitRunnerConfig.CONFIG_FILE) -> void: + console_info( + "Loading test configuration %s\n" % path, + Color.CORNFLOWER_BLUE + ) + _runner_config_file = path + _runner_config.load_config(path) + + +## Shows basic help and exits. +func show_help() -> void: + show_options() + quit(RETURN_SUCCESS) + + +## Shows advanced help and exits. +func show_advanced_help() -> void: + show_options(true) + quit(RETURN_SUCCESS) + + +## Gets command line arguments.[br] +## Returns debug args if set, otherwise actual command line args. +func get_cmdline_args() -> PackedStringArray: + if _debug_cmd_args.is_empty(): + return OS.get_cmdline_args() + return _debug_cmd_args + + +## Initializes the test runner and processes command line arguments. +func init_gd_unit() -> void: + console_info( + """ + -------------------------------------------------------------------------------------------------- + GdUnit4 Comandline Tool + --------------------------------------------------------------------------------------------------""".dedent(), + Color.DARK_SALMON + ) + + var cmd_parser := CmdArgumentParser.new(_cmd_options, "GdUnitCmdTool.gd") + var result := cmd_parser.parse(get_cmdline_args()) + if result.is_error(): + console_error(result.error_message()) + show_options() + console_error("Abnormal exit with %d" % RETURN_ERROR) + quit(RETURN_ERROR) + return + if result.is_empty(): + show_help() + return + # build runner config by given commands + var commands :Array[CmdCommand] = [] + @warning_ignore("unsafe_cast") + commands.append_array(result.value() as Array) + result = ( + CmdCommandHandler.new(_cmd_options) + .register_cb("-help", show_help) + .register_cb("--help-advanced", show_advanced_help) + .register_cb("-a", add_test_suite) + .register_cbv("-a", add_test_suites) + .register_cb("-i", skip_test_suite) + .register_cbv("-i", skip_test_suites) + .register_cb("-rd", set_report_dir) + .register_cb("-rc", set_report_count) + .register_cb("--selftest", run_self_test) + .register_cb("-c", disable_fail_fast) + .register_cb("-conf", load_test_config) + .register_cb("--info", show_version) + .register_cb("--ignoreHeadlessMode", check_headless_mode) + .execute(commands) + ) + if result.is_error(): + console_error(result.error_message()) + quit(RETURN_ERROR) + return + + if DisplayServer.get_name() == "headless": + if _headless_mode_ignore: + console_warning(""" + Headless mode is ignored by option '--ignoreHeadlessMode'" + + Please note that tests that use UI interaction do not work correctly in headless mode. + Godot 'InputEvents' are not transported by the Godot engine in headless mode and therefore + have no effect in the test! + """.dedent() + ) + else: + console_error(""" + Headless mode is not supported! + + Please note that tests that use UI interaction do not work correctly in headless mode. + Godot 'InputEvents' are not transported by the Godot engine in headless mode and therefore + have no effect in the test! + + You can run with '--ignoreHeadlessMode' to swtich off this check. + """.dedent() + ) + console_error( + "Abnormal exit with %d" % RETURN_ERROR_HEADLESS_NOT_SUPPORTED + ) + quit(RETURN_ERROR_HEADLESS_NOT_SUPPORTED) + return + + var script_error_collector := GdUnitScriptErrorCollector.new() + _test_cases = discover_tests() + + # Check for script errors captured during discovery + if script_error_collector.has_errors(): + console_error("Script errors were detected during test discovery!") + for error in script_error_collector.errors(): + console_info(" %s" % error) + console_error("Abnormal exit with %d" % RETURN_ERROR_SCRIPT_ERRORS_DETECTED) + await quit(RETURN_ERROR_SCRIPT_ERRORS_DETECTED) + return + + if _test_cases.is_empty(): + console_info("No test cases found, abort test run!", Color.YELLOW) + console_info("Exit code: %d" % RETURN_SUCCESS, Color.DARK_SALMON) + await quit(RETURN_SUCCESS) + return + _state = RUN + + +func discover_tests() -> Array[GdUnitTestCase]: + var gdunit_test_discover_added := GdUnitSignals.instance().gdunit_test_discover_added + + _test_cases = _runner_config.test_cases() + var scanner := GdUnitTestSuiteScanner.new() + for path in _included_tests: + var scripts := scanner.scan(path) + for script in scripts: + GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void: + if not is_skipped(test): + #_console.println_message("discoverd %s" % test.display_name) + _test_cases.append(test) + gdunit_test_discover_added.emit(test) + ) + + return _test_cases + + +func add_test_suite(path: String) -> void: + _included_tests.append(path) + + +func add_test_suites(paths: PackedStringArray) -> void: + _included_tests.append_array(paths) + + +func skip_test_suite(path: String) -> void: + _excluded_tests.append(path) + + +func skip_test_suites(paths: PackedStringArray) -> void: + _excluded_tests.append_array(paths) + + +func is_skipped(test: GdUnitTestCase) -> bool: + for skipped_info in _excluded_tests: + + # is suite skipped by full path or suite name + if skipped_info == test.suite_name or test.source_file.contains(skipped_info): + return true + var skip_file := skipped_info.replace("res://", "") + + # check for skipped single test + if not skip_file.contains(":"): + continue + var parts: PackedStringArray = skip_file.rsplit(":") + var skipped_suite := parts[0] + var skipped_test := parts[1] + # is suite skipped by full path or suite name + if (skipped_suite == test.suite_name or test.source_file.contains(skipped_suite)) and skipped_test == test.test_name: + return true + + return false + + +func _on_send_message(message: String) -> void: + _console.color(Color.CORNFLOWER_BLUE).println_message(message) + + +func _on_gdunit_event(event: GdUnitEvent) -> void: + match event.type(): + GdUnitEvent.SESSION_START: + _console_reporter.test_session = _test_session + GdUnitEvent.SESSION_CLOSE: + _console_reporter.test_session = null + + +func report_exit_code() -> int: + if _console_reporter.total_error_count() + _console_reporter.total_failure_count() > 0: + console_info("Exit code: %d" % RETURN_ERROR, Color.FIREBRICK) + return RETURN_ERROR + if _console_reporter.total_orphan_count() > 0: + console_info("Exit code: %d" % RETURN_WARNING, Color.GOLDENROD) + return RETURN_WARNING + console_info("Exit code: %d" % RETURN_SUCCESS, Color.DARK_SALMON) + return RETURN_SUCCESS diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid new file mode 100644 index 0000000..b4546a9 --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid @@ -0,0 +1 @@ +uid://dvpgjq00onjd diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd new file mode 100644 index 0000000..9132878 --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd @@ -0,0 +1,79 @@ +extends "res://addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd" +## Runner implementation used by the editor UI.[br] +## [br] +## This runner connects to a GdUnit server via TCP to report test results.[br] +## Test results are reported in real-time and displayed in the editor UI.[br] +## [br] +## The runner uses an RPC message protocol to communicate status and events:[br] +## - Messages to report progress[br] +## - Events to report test results[br] + +## The TCP client used to connect to the GdUnit server +@onready var _client: GdUnitTcpClient = $GdUnitTcpClient +@onready var _version_label: Control = %Version + + +func _init() -> void: + super() + # We set the default max report history to 1 + max_report_history = 1 + + +func _ready() -> void: + super() + GdUnit4Version.init_version_label(_version_label) + + var config_result := _runner_config.load_config() + if config_result.is_error(): + push_error(config_result.error_message()) + _state = EXIT + return + @warning_ignore("return_value_discarded") + _client.connection_failed.connect(_on_connection_failed) + GdUnitSignals.instance().gdunit_message.connect(_on_send_message) + _executor.fail_fast(_runner_config.is_fail_fast()) + var result := _client.start("127.0.0.1", _runner_config.server_port()) + if result.is_error(): + push_error(result.error_message()) + return + + +## Called when the TCP connection to the GdUnit server fails.[br] +## Stops the test execution.[br] +## [br] +## [param message] The error message describing the failure. +func _on_connection_failed(message: String) -> void: + prints("_on_connection_failed", message) + _state = STOP + + +## Initializes the test runner.[br] +## Waits for TCP client connection and then scans for test suites.[br] +## Reports the number of found test suites via TCP message. +func init_runner() -> void: + # wait until client is connected to the GdUnitServer + if _client.is_client_connected(): + await gdUnitInit() + _state = RUN + + +## Initializes the GdUnit framework.[br] +## Sends initial message about number of test suites. +func gdUnitInit() -> void: + #enable_manuall_polling() + _test_cases = _runner_config.test_cases() + await get_tree().process_frame + + +## Sends a message via TCP to the GdUnit server.[br] +## [br] +## [param message] The message to send. +func _on_send_message(message: String) -> void: + _client.send(RPCMessage.of(message)) + + +## Handles GdUnit events by sending them via TCP to the server.[br] +## [br] +## [param event] The event to send. +func _on_gdunit_event(event: GdUnitEvent) -> void: + _client.send(RPCGdUnitEvent.of(event)) diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid new file mode 100644 index 0000000..90754db --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid @@ -0,0 +1 @@ +uid://biwsook2ynbb1 diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn new file mode 100644 index 0000000..eaa6f1a --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn @@ -0,0 +1,34 @@ +[gd_scene load_steps=3 format=3 uid="uid://belidlfknh74r"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd" id="1"] +[ext_resource type="Script" path="res://addons/gdUnit4/src/network/GdUnitTcpClient.gd" id="2"] + +[node name="Control" type="Node"] +script = ExtResource("1") + +[node name="GdUnitTcpClient" type="Node" parent="."] +script = ExtResource("2") + +[node name="HBoxContainer" type="HBoxContainer" parent="."] +custom_minimum_size = Vector2(0, 24) +layout_direction = 2 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 0 +size_flags_horizontal = 3 +size_flags_vertical = 10 +alignment = 2 + +[node name="Version" type="RichTextLabel" parent="HBoxContainer"] +unique_name_in_owner = true +custom_minimum_size = Vector2(128, 0) +layout_mode = 2 +size_flags_horizontal = 10 +bbcode_enabled = true +scroll_active = false +shortcut_keys_enabled = false +horizontal_alignment = 1 +justification_flags = 0 diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd new file mode 100644 index 0000000..c789847 --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd @@ -0,0 +1,169 @@ +## +## @since GdUnit4 5.1.0 +## +## Represents a test execution session in GdUnit4.[br] +## [br] +## [i]A test session encapsulates a complete test execution cycle, managing the collection +## of test cases to be executed and providing communication channels for test events +## and messages. This class serves as the central coordination point for test execution +## and allows hooks and other components to interact with the running test session.[/i][br] +## [br] +## [b][u]Key Features[/u][/b][br] +## - [i][b]Test Case Management[/b][/i]: Maintains a collection of test cases to be executed[br] +## - [i][b]Event Broadcasting[/b][/i]: Forwards GdUnit events to session-specific listeners[br] +## - [i][b]Message Communication[/b][/i]: Provides a channel for sending messages during test execution[br] +## - [i][b]Hook Integration[/b][/i]: Passed to test session hooks for startup and shutdown operations[br] +## [br] +## [b][u]Usage in Test Hooks[/u][/b] +## [codeblock] +## func startup(session: GdUnitTestSession) -> GdUnitResult: +## # Access test cases +## print("Running %d test cases" % session.test_cases.size()) +## +## # Send status messages +## session.send_message("Custom hook initialized") +## +## # Listen for test events +## session.test_event.connect(_on_test_event) +## +## return GdUnitResult.success() +## +## func _on_test_event(event: GdUnitEvent) -> void: +## print("Test event received: %s" % event.type) +## [/codeblock] +## [br] +## [b][u]Event Flow[/u][/b][br] +## 1. Session is created with a collection of test cases[br] +## 2. Session connects to the global GdUnit event system[br] +## 3. During test execution, events are automatically forwarded to session listeners[br] +## 4. Hooks and other components can subscribe to session events[br] +## 5. Messages can be sent through the session for logging and communication[br] +class_name GdUnitTestSession +extends RefCounted + + +## Emitted when a test execution event occurs.[br] +## [br] +## [i]This signal forwards events from the global GdUnit event system to session-specific +## listeners. It allows hooks and other session components to react to test events +## without directly connecting to the global event system.[/i][br] +## [br] +## [u]Common event types include:[/u][br] +## - Test suite start/end events[br] +## - Test case start/end events[br] +## - Test assertion events[br] +## - Test failure/error events[br] +## +## [param event] The test event containing details about test execution, timing, and results +@warning_ignore("unused_signal") +signal test_event(event: GdUnitEvent) + + +## [b][color=red]@readonly: Should not be modified directly during test execution![/color][/b][br] +## Collection of test cases to be executed in this session.[br] +## [br] +## This array contains all the test cases that will be run during the session. +## Test hooks can access this collection to: +## - Get the total number of tests to be executed +## - Access individual test case metadata +## - Perform setup/teardown based on test case requirements +## - Generate reports or statistics about the test suite +## +## The collection is typically populated before session startup and remains +## constant during test execution. +var _test_cases : Array[GdUnitTestCase] = [] + + +## [b][color=red]@readonly: The report path should not be modified after session creation![/color][/b][br] +## The file system path where test reports for this session will be generated.[br] +## [br] +## [i]This property provides centralized access to the report output location, +## allowing test hooks, reporters, and other components to reference the same +## report path without coupling to specific reporter implementations.[/i][br] +## [br] +## [b][u]Common use cases include:[/u][/b][br] +## - Test hooks generating additional report files in the same directory[br] +## - Custom reporters creating supplementary output files[br] +## - Post-processing scripts that need to locate generated reports[br] +## - Cleanup operations that need to manage report artifacts[br] +## [br] +## [b][u]Example Usage:[/u][/b] +## [codeblock] +## # In a test hook +## func startup(session: GdUnitTestSession) -> GdUnitResult: +## var report_dir = session.report_path.get_base_dir() +## var custom_report = report_dir.path_join("custom_metrics.json") +## # Generate additional reports in the same location +## return GdUnitResult.success() +## +## func shutdown(session: GdUnitTestSession) -> GdUnitResult: +## session.send_message("Reports available at: " + session.report_path) +## return GdUnitResult.success() +## [/codeblock] +## [br] +## The path is set during session initialization and remains constant throughout +## the test execution lifecycle. +var report_path: String: + get: + return report_path + + +## Initializes the test session and sets up event forwarding.[br] +## [br] +## [i]This constructor automatically connects to the global GdUnit event system +## and forwards all events to the session's test_event signal. This allows +## session-specific components to listen for test events without managing +## global signal connections.[/i] +func _init(test_cases: Array[GdUnitTestCase], session_report_path: String) -> void: + # We build a copy to prevent a user is modifing the tests + _test_cases = test_cases.duplicate(true) + report_path = session_report_path + GdUnitSignals.instance().gdunit_event.connect(func(event: GdUnitEvent) -> void: + test_event.emit(event) + ) + + +## Finds a test case by its unique identifier.[br] +## [br] +## [i]Searches through all test cases to find a test with the matching GUID.[/i][br] +## [br] +## [param id] The GUID of the test to find[br] +## Returns the matching test case or null if not found. +func find_test_by_id(id: GdUnitGUID) -> GdUnitTestCase: + for test in _test_cases: + if test.guid.equals(id): + return test + + return null + + +## Sends a message through the GdUnit messaging system.[br] +## [br] +## [i]This method provides a convenient way for test hooks and other session +## components to send messages that will be handled by the GdUnit framework.[/i] +## [br][br] +## [b][u]Messages are typically used for:[/u][/b][br] +## - Status updates during test execution[br] +## - Progress reporting from test hooks[br] +## - Debug information and logging[br] +## - User notifications and alerts[br] +## [br] +## The message will be processed by the global GdUnit message system and +## may be displayed in the test runner UI, logged to files, or handled +## by other registered message handlers. +## [br] +## [b][u]Example Usage:[/u][/b] +## [codeblock] +## # In a test hook +## func startup(session: GdUnitTestSession) -> GdUnitResult: +## session.send_message("Database connection established") +## return GdUnitResult.success() +## +## func shutdown(session: GdUnitTestSession) -> GdUnitResult: +## session.send_message("Generated test report: report.html") +## return GdUnitResult.success() +## ``` +## [/codeblock] +## [param message] The message text to send through the GdUnit messaging system +func send_message(message: String) -> void: + GdUnitSignals.instance().gdunit_message.emit(message) diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid new file mode 100644 index 0000000..4189cdf --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid @@ -0,0 +1 @@ +uid://bbico750mqlg4 diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd new file mode 100644 index 0000000..0eec866 --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd @@ -0,0 +1,191 @@ +extends Node +## The base test runner implementation.[br] +## [br] +## This class provides the core functionality to execute test suites with following features:[br] +## - Loading and initialization of test suites[br] +## - Executing test suites and managing test states[br] +## - Event dispatching and test reporting[br] +## - Support for headless mode[br] +## - Plugin version verification[br] +## [br] +## Supported by specialized runners:[br] +## - [b]GdUnitTestRunner[/b]: Used in the editor, connects via tcp to report test results[br] +## - [b]GdUnitCLRunner[/b]: A command line interface runner, writes test reports to file[br] +## The test runner runs checked default in fail-fast mode, it stops checked first test failure. + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +## Overall test run status codes used by the runners +const RETURN_SUCCESS = 0 +const RETURN_ERROR = 100 +const RETURN_ERROR_HEADLESS_NOT_SUPPORTED = 103 +const RETURN_ERROR_GODOT_VERSION_NOT_SUPPORTED = 104 +const RETURN_ERROR_SCRIPT_ERRORS_DETECTED = 105 +const RETURN_WARNING = 101 + +## Specifies the Node name under which the runner is registered +const GDUNIT_RUNNER = "GdUnitRunner" + +## The current runner configuration +@warning_ignore("unused_private_class_variable") +var _runner_config := GdUnitRunnerConfig.new() + +## The test suite executor instance +var _executor: GdUnitTestSuiteExecutor +var _hooks : GdUnitTestSessionHookService + +## Current runner state +var _state := READY + +## Current tests to be processed +var _test_cases: Array[GdUnitTestCase] = [] + + +## Configured report base path (can be set on CI test runner) +var report_base_path: String = GdUnitFileAccess.current_dir() + "reports": + get: + return report_base_path + + +## Current session report path +var report_path: String: + get: + return "%s/%s%d" % [report_base_path, GdUnitConstants.REPORT_DIR_PREFIX, current_report_history_index] + + +## Current report history index, if max_report_history > 1 we scan for the next index over the existing reports +var current_report_history_index: int: + get: + if max_report_history > 1: + return GdUnitFileAccess.find_last_path_index(report_base_path, GdUnitConstants.REPORT_DIR_PREFIX) + 1 + else: + return 1 + + +## Controls how many report historys will be hold +var max_report_history: int = GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT: + get: + return max_report_history + set(value): + max_report_history = value + + +# holds the current test session context +var _test_session: GdUnitTestSession +var _is_editor_debug_run: bool + +## Runner state machine +enum { + READY, + INIT, + RUN, + STOP, + EXIT +} + +func _init() -> void: + _is_editor_debug_run = OS.get_cmdline_args().has("--scene") + if _is_editor_debug_run: + DisplayServer.window_set_title("GdUnit4 Runner (Debug Mode)") + else: + DisplayServer.window_set_title("GdUnit4 Runner (Release Mode)") + if not Engine.is_embedded_in_editor(): + # minimize scene window checked debug mode + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED) + # store current runner instance to engine meta data to can be access in as a singleton + Engine.set_meta(GDUNIT_RUNNER, self) + + +# Called when the node enters the scene tree for the first time. +func _ready() -> void: + if Engine.get_version_info().hex < 0x40300: + printerr("The GdUnit4 plugin requires Godot version 4.3 or higher to run.") + quit(RETURN_ERROR_GODOT_VERSION_NOT_SUPPORTED) + return + _executor = GdUnitTestSuiteExecutor.new() + + GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event) + _state = INIT + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + Engine.remove_meta(GDUNIT_RUNNER) + + +## Main test runner loop. Is called every frame to manage the test execution. +func _process(_delta: float) -> void: + match _state: + INIT: + await init_runner() + RUN: + _hooks = GdUnitTestSessionHookService.instance() + _test_session = GdUnitTestSession.new(_test_cases, report_path) + GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionStart.new()) + # process next test suite + set_process(false) + var result := await _hooks.execute_startup(_test_session) + if result.is_error(): + push_error(result.error_message()) + await _executor.run_and_wait(_test_cases) + result = await _hooks.execute_shutdown(_test_session) + if result.is_error(): + push_error(result.error_message()) + _state = STOP + set_process(true) + GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionClose.new()) + cleanup_report_history() + STOP: + _state = EXIT + # give the engine small amount time to finish the rpc + await get_tree().create_timer(0.1).timeout + await quit(get_exit_code()) + + +## Used by the inheriting runners to initialize test execution +func init_runner() -> void: + await get_tree().process_frame + + +func cleanup_report_history() -> int: + return GdUnitFileAccess.delete_path_index_lower_equals_than( + report_path.get_base_dir(), + GdUnitConstants.REPORT_DIR_PREFIX, + current_report_history_index-1-max_report_history) + + +## Returns the exit code when the test run is finished.[br] +## Abstract method to be implemented by the inheriting runners. +func get_exit_code() -> int: + return RETURN_SUCCESS + + +## Quits the test runner with given exit code. +func quit(_code: int) -> void: + await GdUnitMemoryObserver.gc_on_guarded_instances() + + if !_is_editor_debug_run: + # Only dispose all resources when we not run embedded in the editor + GdUnitTools.dispose_all() + await get_tree().process_frame + + await get_tree().process_frame + await get_tree().physics_frame + + +func prints_warning(message: String) -> void: + prints(message) + + +## Default event handler to process test events.[br] +## Should be overridden by concrete runner implementation. +@warning_ignore("unused_parameter") +func _on_gdunit_event(event: GdUnitEvent) -> void: + pass + + +## Event bridge from C# GdUnit4.ITestEventListener.cs[br] +## Used to handle test events from C# tests. +# gdlint: disable=function-name +func PublishEvent(data: Dictionary) -> void: + _on_gdunit_event(GdUnitEvent.new().deserialize(data)) diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid new file mode 100644 index 0000000..f3858af --- /dev/null +++ b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid @@ -0,0 +1 @@ +uid://c65ulvqoi3r2a diff --git a/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd new file mode 100644 index 0000000..20325ac --- /dev/null +++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd @@ -0,0 +1,36 @@ +class_name GdUnitTestSuiteDefaultTemplate +extends RefCounted + + +const DEFAULT_TEMP_TS_GD =""" + # GdUnit generated TestSuite + class_name ${suite_class_name} + extends GdUnitTestSuite + @warning_ignore('unused_parameter') + @warning_ignore('return_value_discarded') + + # TestSuite generated from + const __source: String = '${source_resource_path}' +""" + + +const DEFAULT_TEMP_TS_CS = """ + // GdUnit generated TestSuite + + using Godot; + using GdUnit4; + + namespace ${name_space} + { + using static Assertions; + using static Utils; + + [TestSuite] + public class ${suite_class_name} + { + // TestSuite generated from + private const string sourceClazzPath = "${source_resource_path}"; + + } + } +""" diff --git a/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid new file mode 100644 index 0000000..f6d688a --- /dev/null +++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid @@ -0,0 +1 @@ +uid://dfp3rbns2k26f diff --git a/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd new file mode 100644 index 0000000..6fc282d --- /dev/null +++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd @@ -0,0 +1,144 @@ +class_name GdUnitTestSuiteTemplate +extends RefCounted + +const TEMPLATE_ID_GD = 1000 +const TEMPLATE_ID_CS = 2000 + +const SUPPORTED_TAGS_GD = """ + GdScript Tags are replaced when the test-suite is created. + + # The class name of the test-suite, formed from the source script. + ${suite_class_name} + # is used to build the test suite class name + class_name ${suite_class_name} + extends GdUnitTestSuite + + + # The class name in pascal case, formed from the source script. + ${source_class} + # can be used to create the class e.g. for source 'MyClass' + var my_test_class := ${source_class}.new() + # will be result in + var my_test_class := MyClass.new() + + # The class as variable name in snake case, formed from the source script. + ${source_var} + # Can be used to build the variable name e.g. for source 'MyClass' + var ${source_var} := ${source_class}.new() + # will be result in + var my_class := MyClass.new() + + # The full resource path from which the file was created. + ${source_resource_path} + # Can be used to load the script in your test + var my_script := load(${source_resource_path}) + # will be result in + var my_script := load("res://folder/my_class.gd") +""" + +const SUPPORTED_TAGS_CS = """ + C# Tags are replaced when the test-suite is created. + + // The namespace name of the test-suite + ${name_space} + namespace ${name_space} + + // The class name of the test-suite, formed from the source class. + ${suite_class_name} + // is used to build the test suite class name + [TestSuite] + public class ${suite_class_name} + + // The class name formed from the source class. + ${source_class} + // can be used to create the class e.g. for source 'MyClass' + private string myTestClass = new ${source_class}(); + // will be result in + private string myTestClass = new MyClass(); + + // The class as variable name in camelCase, formed from the source class. + ${source_var} + // Can be used to build the variable name e.g. for source 'MyClass' + private object ${source_var} = new ${source_class}(); + // will be result in + private object myClass = new MyClass(); + + // The full resource path from which the file was created. + ${source_resource_path} + // Can be used to load the script in your test + private object myScript = GD.Load(${source_resource_path}); + // will be result in + private object myScript = GD.Load("res://folder/MyClass.cs"); +""" + +const TAG_TEST_SUITE_CLASS = "${suite_class_name}" +const TAG_SOURCE_CLASS_NAME = "${source_class}" +const TAG_SOURCE_CLASS_VARNAME = "${source_var}" +const TAG_SOURCE_RESOURCE_PATH = "${source_resource_path}" + + +static func default_GD_template() -> String: + return GdUnitTestSuiteDefaultTemplate.DEFAULT_TEMP_TS_GD.dedent().trim_prefix("\n") + + +static func default_CS_template() -> String: + return GdUnitTestSuiteDefaultTemplate.DEFAULT_TEMP_TS_CS.dedent().trim_prefix("\n") + + +static func build_template(source_path: String) -> String: + var clazz_name :String = GdObjects.to_pascal_case(GdObjects.extract_class_name(source_path).value_as_string()) + var template: String = GdUnitSettings.get_setting(GdUnitSettings.TEMPLATE_TS_GD, default_GD_template()) + + return template\ + .replace(TAG_TEST_SUITE_CLASS, clazz_name+"Test")\ + .replace(TAG_SOURCE_RESOURCE_PATH, source_path)\ + .replace(TAG_SOURCE_CLASS_NAME, clazz_name)\ + .replace(TAG_SOURCE_CLASS_VARNAME, GdObjects.to_snake_case(clazz_name)) + + +static func default_template(template_id :int) -> String: + if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS: + push_error("Invalid template '%d' id! Cant load testsuite template" % template_id) + return "" + if template_id == TEMPLATE_ID_GD: + return default_GD_template() + return default_CS_template() + + +static func load_template(template_id :int) -> String: + if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS: + push_error("Invalid template '%d' id! Cant load testsuite template" % template_id) + return "" + if template_id == TEMPLATE_ID_GD: + return GdUnitSettings.get_setting(GdUnitSettings.TEMPLATE_TS_GD, default_GD_template()) + return GdUnitSettings.get_setting(GdUnitSettings.TEMPLATE_TS_CS, default_CS_template()) + + +static func save_template(template_id :int, template :String) -> void: + if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS: + push_error("Invalid template '%d' id! Cant load testsuite template" % template_id) + return + if template_id == TEMPLATE_ID_GD: + GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_GD, template.dedent().trim_prefix("\n")) + elif template_id == TEMPLATE_ID_CS: + GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_CS, template.dedent().trim_prefix("\n")) + + +static func reset_to_default(template_id :int) -> void: + if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS: + push_error("Invalid template '%d' id! Cant load testsuite template" % template_id) + return + if template_id == TEMPLATE_ID_GD: + GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_GD, default_GD_template()) + else: + GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_CS, default_CS_template()) + + +static func load_tags(template_id :int) -> String: + if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS: + push_error("Invalid template '%d' id! Cant load testsuite template" % template_id) + return "Error checked loading tags" + if template_id == TEMPLATE_ID_GD: + return SUPPORTED_TAGS_GD + else: + return SUPPORTED_TAGS_CS diff --git a/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid new file mode 100644 index 0000000..876d0c4 --- /dev/null +++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid @@ -0,0 +1 @@ +uid://dgwe6f1mrcscc diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd new file mode 100644 index 0000000..89bc48b --- /dev/null +++ b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd @@ -0,0 +1,71 @@ +class_name GdUnitThreadContext +extends RefCounted + +var _thread: Thread +var _thread_name: String +var _thread_id: int +var _signal_collector: GdUnitSignalCollector +var _execution_context: GdUnitExecutionContext +var _asserts := [] + + +func _init(thread: Thread = null) -> void: + if thread != null: + _thread = thread + _thread_name = thread.get_meta("name") + _thread_id = thread.get_id() as int + else: + _thread_name = "main" + _thread_id = OS.get_main_thread_id() + _signal_collector = GdUnitSignalCollector.new() + + +func dispose() -> void: + clear_assert() + if is_instance_valid(_signal_collector): + _signal_collector.clear() + _signal_collector = null + _execution_context = null + _thread = null + + +func terminate() -> void: + if _execution_context: + _execution_context.terminate() + + +func clear_assert() -> void: + _asserts.clear() + + +func set_assert(value: GdUnitAssert) -> void: + if value != null: + _asserts.append(value) + + +func get_assert() -> GdUnitAssert: + return null if _asserts.is_empty() else _asserts[-1] + + +func set_execution_context(context: GdUnitExecutionContext) -> void: + _execution_context = context + + +func get_execution_context() -> GdUnitExecutionContext: + return _execution_context + + +func get_execution_context_id() -> int: + return _execution_context.get_instance_id() + + +func get_signal_collector() -> GdUnitSignalCollector: + return _signal_collector + + +func thread_id() -> int: + return _thread_id + + +func _to_string() -> String: + return "ThreadContext <%s>: %s " % [_thread_name, _thread_id] diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid new file mode 100644 index 0000000..4bf0837 --- /dev/null +++ b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid @@ -0,0 +1 @@ +uid://cwc24uswt0hle diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd new file mode 100644 index 0000000..2e694e2 --- /dev/null +++ b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd @@ -0,0 +1,69 @@ +## A manager to run new thread and crate a ThreadContext shared over the actual test run +class_name GdUnitThreadManager +extends Object + +## { = } +var _thread_context_by_id: Dictionary[int, GdUnitThreadContext] = {} +## holds the current thread id +var _current_thread_id :int = -1 + +func _init() -> void: + # add initail the main thread + _current_thread_id = OS.get_thread_caller_id() + _thread_context_by_id[OS.get_main_thread_id()] = GdUnitThreadContext.new() + + +static func instance() -> GdUnitThreadManager: + return GdUnitSingleton.instance("GdUnitThreadManager", func() -> GdUnitThreadManager: return GdUnitThreadManager.new()) + + +## Runs a new thread by given name and Callable.[br] +## A new GdUnitThreadContext is created, which is used for the actual test execution.[br] +## We need this custom implementation while this bug is not solved +## Godot issue https://github.com/godotengine/godot/issues/79637 +static func run(name :String, cb :Callable) -> Variant: + return await instance()._run(name, cb) + + +static func interrupt() -> void: + for thread_context: GdUnitThreadContext in instance()._thread_context_by_id.values(): + thread_context.terminate() + + +## Returns the current valid thread context +static func get_current_context() -> GdUnitThreadContext: + return instance()._get_current_context() + + +func _run(name :String, cb :Callable) -> Variant: + # we do this hack because of `OS.get_thread_caller_id()` not returns the current id + # when await process_frame is called inside the fread + var save_current_thread_id := _current_thread_id + var thread := Thread.new() + thread.set_meta("name", name) + @warning_ignore("return_value_discarded") + thread.start(cb) + _current_thread_id = thread.get_id() as int + _register_thread(thread, _current_thread_id) + var result :Variant = await thread.wait_to_finish() + _unregister_thread(_current_thread_id) + # restore original thread id + _current_thread_id = save_current_thread_id + return result + + +func _register_thread(thread :Thread, thread_id :int) -> void: + var context := GdUnitThreadContext.new(thread) + _thread_context_by_id[thread_id] = context + + +func _unregister_thread(thread_id :int) -> void: + var context: GdUnitThreadContext = _thread_context_by_id.get(thread_id) + if context: + @warning_ignore("return_value_discarded") + _thread_context_by_id.erase(thread_id) + context.dispose() + + +func _get_current_context() -> GdUnitThreadContext: + return _thread_context_by_id.get(_current_thread_id) diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid new file mode 100644 index 0000000..9db1d81 --- /dev/null +++ b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid @@ -0,0 +1 @@ +uid://dnv3tyqj4hphe diff --git a/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd new file mode 100644 index 0000000..c7eb9f5 --- /dev/null +++ b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd @@ -0,0 +1,235 @@ +@tool +class_name GdUnitCSIMessageWriter +extends GdUnitMessageWriter +## A message writer implementation using ANSI/CSI escape codes for console output.[br] +## [br] +## This writer provides formatted message output using CSI (Control Sequence Introducer) codes.[br] +## It supports:[br] +## - Color using RGB values[br] +## - Text styles (bold, italic, underline)[br] +## - Cursor positioning and text alignment[br] +## [br] +## Used primarily for console-based test execution and CI/CD environments. + + +enum { + COLOR_TABLE, + COLOR_RGB +} + +const CSI_BOLD = "" +const CSI_ITALIC = "" +const CSI_UNDERLINE = "" +const CSI_RESET = "" + +# Control Sequence Introducer +var _debug_show_color_codes := false +var _color_mode := COLOR_TABLE + +## Current cursor position in the line +var _current_pos := 0 + +# Pre-compiled regex patterns for tag matching +var _tag_regex: RegEx + + +## Constructs CSI style codes based on flags.[br] +## [br] +## [param flags] The style flags to apply (BOLD, ITALIC, UNDERLINE).[br] +## Returns the corresponding CSI codes. +func _apply_style_flags(flags: int) -> String: + var _style := "" + if flags & BOLD: + _style += CSI_BOLD + if flags & ITALIC: + _style += CSI_ITALIC + if flags & UNDERLINE: + _style += CSI_UNDERLINE + return _style + + +## Converts a color string (named or hex) to a Color object +func _parse_color(color_str: String) -> Color: + return Color.from_string(color_str.strip_edges().to_lower(), Color.WHITE) + + +## Generates CSI color code for foreground color +func _color_to_csi_fg(c: Color) -> String: + return "[38;2;%d;%d;%dm" % [c.r8 * c.a, c.g8 * c.a, c.b8 * c.a] + + +## Generates CSI color code for background color +func _color_to_csi_bg(c: Color) -> String: + return "[48;2;%d;%d;%dm" % [c.r8 * c.a, c.g8 * c.a, c.b8 * c.a] + + +func _init_regex_patterns() -> void: + if not _tag_regex: + _tag_regex = RegEx.new() + # Match all richtext tags: [tag], [tag=value], [/tag] + _tag_regex.compile(r"\[/?(?:color|bgcolor|b|i|u)(?:=[^\]]+)?\]") + + +func _extract_color_from_tag(tag: String, tag_assign: String) -> Color: + var tag_assign_length := tag_assign.length() + var color_value := tag.substr(tag_assign_length, tag.length() - tag_assign_length - 1) + return _parse_color(color_value) + + +## Optimized richtext to CSI conversion using regex and lookup processing +func _bbcode_tags_to_csi_codes(message: String) -> String: + _init_regex_patterns() + + var result := "" + var last_pos := 0 + var color_stack: Array[Color] = [] + var bgcolor_stack: Array[Color] = [] + + # Find all richtext tags + var matches := _tag_regex.search_all(message) + + for match in matches: + var start_pos := match.get_start() + var end_pos := match.get_end() + var tag := match.get_string(0) + + # Add text before this tag + result += message.substr(last_pos, start_pos - last_pos) + + # Process the tag + if tag.begins_with("[color="): + var fg_color := _extract_color_from_tag(tag, "[color=") + color_stack.push_back(fg_color) + result += _color_to_csi_fg(fg_color) + elif tag.begins_with("[bgcolor="): + var bg_color := _extract_color_from_tag(tag, "[bgcolor=") + bgcolor_stack.push_back(bg_color) + result += _color_to_csi_bg(bg_color) + elif tag == "[b]": + result += CSI_BOLD + elif tag == "[i]": + result += CSI_ITALIC + elif tag == "[u]": + result += CSI_UNDERLINE + elif tag == "[/color]": + result += CSI_RESET + if color_stack.size() > 0: + color_stack.pop_back() + # Restore remaining styles and colors + if color_stack.size() > 0: + result += _color_to_csi_fg(color_stack[-1]) + if bgcolor_stack.size() > 0: + result += _color_to_csi_bg(bgcolor_stack[-1]) + elif tag == "[/bgcolor]": + result += CSI_RESET + if bgcolor_stack.size() > 0: + bgcolor_stack.pop_back() + # Restore remaining styles and colors + if color_stack.size() > 0: + result += _color_to_csi_fg(color_stack[-1]) + if bgcolor_stack.size() > 0: + result += _color_to_csi_bg(bgcolor_stack[-1]) + elif tag in ["[/b]", "[/i]", "[/u]"]: + result += CSI_RESET + # Restore remaining colors after style reset + if color_stack.size() > 0: + result += _color_to_csi_fg(color_stack[-1]) + if bgcolor_stack.size() > 0: + result += _color_to_csi_bg(bgcolor_stack[-1]) + + last_pos = end_pos + + # Add remaining text after last tag + result += message.substr(last_pos) + + return result + + +## Internal implementation of print_stack_trace.[br] +## [br] +## [param stack_trace] The stack trace to print.[br] +## [param _indent] The indentation level. +func _print_stack_trace(stack_trace: GdUnitStackTrace, _indent: int) -> void: + _print_message(stack_trace.print_stack_trace(), Color.LIGHT_BLUE, _indent, 0) + + +## Implementation of basic message output with formatting. +func _print_message(_message: String, _color: Color, _indent: int, _flags: int) -> void: + var text := _bbcode_tags_to_csi_codes(_message) + var indent_text := "".lpad(_indent * 2) + var _style := _apply_style_flags(_flags) + printraw("%s[38;2;%d;%d;%dm%s%s" % [indent_text, _color.r8, _color.g8, _color.b8, _style, text]) + _current_pos += _indent * 2 + text.length() + + +## Implementation of line-ending message output with formatting. +func _println_message(_message: String, _color: Color, _indent: int, _flags: int) -> void: + _print_message(_message, _color, _indent, _flags) + prints() + _current_pos = 0 + + +## Implementation of positioned message output with formatting. +func _print_at(_message: String, cursor_pos: int, _color: Color, _effect: GdUnitMessageWriter.Effect, _align: Align, _flags: int) -> void: + if _align == Align.RIGHT: + cursor_pos = cursor_pos - _message.length() + + if cursor_pos > _current_pos: + printraw("[%dG" % cursor_pos) # Move cursor to absolute position + else: + _message = " " + _message + + var _style := _apply_style_flags(_flags) + printraw("[38;2;%d;%d;%dm%s%s" % [_color.r8, _color.g8, _color.b8, _style, _message]) + _current_pos = cursor_pos + _message.length() + + +## Writes a line break and returns self for chaining. +func new_line() -> GdUnitCSIMessageWriter: + prints() + return self + + +## Saves the current cursor position.[br] +## Returns self for chaining. +func save_cursor() -> GdUnitCSIMessageWriter: + printraw("") + return self + + +## Restores previously saved cursor position.[br] +## Returns self for chaining. +func restore_cursor() -> GdUnitCSIMessageWriter: + printraw("") + return self + + +## Clears screen content and resets cursor position. +func clear() -> void: + printraw("") # Clear screen and move cursor to home + _current_pos = 0 + + +## Debug method to display the available color table.[br] +## Shows both 6x6x6 color cube and RGB color modes. +@warning_ignore("return_value_discarded") +func _print_color_table() -> void: + color(Color.ANTIQUE_WHITE).println_message("Color Table 6x6x6") + _debug_show_color_codes = true + for green in range(0, 6): + for red in range(0, 6): + for blue in range(0, 6): + color(Color8(red * 42, green * 42, blue * 42)).println_message("████████ ") + new_line() + new_line() + + color(Color.ANTIQUE_WHITE).println_message("Color Table RGB") + _color_mode = COLOR_RGB + for green in range(0, 6): + for red in range(0, 6): + for blue in range(0, 6): + color(Color8(red * 42, green * 42, blue * 42)).println_message("████████ ") + new_line() + new_line() + _color_mode = COLOR_TABLE + _debug_show_color_codes = false diff --git a/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid new file mode 100644 index 0000000..73fc48c --- /dev/null +++ b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid @@ -0,0 +1 @@ +uid://c0ekjfowev2s4 diff --git a/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd new file mode 100644 index 0000000..d218ab9 --- /dev/null +++ b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd @@ -0,0 +1,229 @@ +@tool +@abstract class_name GdUnitMessageWriter +extends RefCounted +## Base interface class for writing formatted messages to different outputs.[br] +## [br] +## This class defines the interface and common functionality for writing formatted messages.[br] +## It provides a fluent API for message formatting and supports different output targets.[br] +## [br] +## The class provides formatting options for:[br] +## - Text colors[br] +## - Text styles (bold, italic, underline)[br] +## - Text effects (e.g., wave)[br] +## - Text alignment[br] +## - Indentation[br] +## [br] +## Two concrete implementations are available:[br] +## - [GdUnitRichTextMessageWriter] writing to a [RichTextLabel][br] +## - [GdUnitCSIMessageWriter] writing to console using CSI codes[br] +## [br] +## Example usage:[br] +## [codeblock] +## writer.color(Color.RED).style(BOLD).println_message("Test failed!") +## writer.color(Color.GREEN).align(Align.RIGHT).print_at("Success", 80) +## [/codeblock] + + +## Text style flag for bold formatting +const BOLD = 0x1 +## Text style flag for italic formatting +const ITALIC = 0x2 +## Text style flag for underline formatting +const UNDERLINE = 0x4 + + +## Represents special text effects that can be applied to the output +enum Effect { + ## No special effect applied + NONE, + ## Applies a wave animation to the text + WAVE +} + + +## Controls text alignment at the specified cursor position +enum Align { + ## Aligns text to the left of the cursor position + LEFT, + ## Aligns text to the right of the cursor position, accounting for text length + RIGHT +} + + +## The current text color to be used for the next output operation +var _current_color := Color.WHITE + +## The current indentation level to be used for the next output operation.[br] +## Each level represents two spaces of indentation. +var _current_indent := 0 + +## The current text style flags (BOLD, ITALIC, UNDERLINE) to be used for the next output operation +var _current_flags := 0 + +## The current text alignment to be used for the next output operation +var _current_align := Align.LEFT + +## The current text effect to be used for the next output operation +var _current_effect := GdUnitMessageWriter.Effect.NONE + + +## Sets the text color for the next output operation.[br] +## [br] +## [param value] The color to be used for the text. +## Returns self for method chaining. +func color(value: Color) -> GdUnitMessageWriter: + _current_color = value + return self + + +## Sets the indentation level for the next output operation.[br] +## [br] +## [param value] The number of indentation levels, where each level equals two spaces. +## Returns self for method chaining. +func indent(value: int) -> GdUnitMessageWriter: + _current_indent = value + return self + + +## Sets text style flags for the next output operation.[br] +## [br] +## [param value] A combination of style flags (BOLD, ITALIC, UNDERLINE). +## Returns self for method chaining. +func style(value: int) -> GdUnitMessageWriter: + _current_flags = value + return self + + +## Sets text effect for the next output operation.[br] +## [br] +## [param value] The effect to apply to the text (NONE, WAVE). +## Returns self for method chaining. +func effect(value: GdUnitMessageWriter.Effect) -> GdUnitMessageWriter: + _current_effect = value + return self + + +## Sets text alignment for the next output operation.[br] +## [br] +## [param value] The alignment to use (LEFT, RIGHT). +## Returns self for method chaining. +func align(value: Align) -> GdUnitMessageWriter: + _current_align = value + return self + + +## Resets all formatting options to their default values.[br] +## [br] +## Defaults:[br] +## - color: Color.WHITE[br] +## - indent: 0[br] +## - flags: 0[br] +## - align: LEFT[br] +## - effect: NONE[br] +## Returns self for method chaining. +func reset() -> GdUnitMessageWriter: + _current_color = Color.WHITE + _current_indent = 0 + _current_flags = 0 + _current_align = Align.LEFT + _current_effect = Effect.NONE + return self + + +## Prints a warning message in golden color.[br] +## [br] +## [param message] The warning message to print. +func prints_warning(message: String) -> void: + color(Color.GOLDENROD).println_message(message) + + +## Prints an error message in crimson color.[br] +## [br] +## [param message] The error message to print. +func prints_error(message: String) -> void: + color(Color.CRIMSON).println_message(message) + + +## Prints a message with current formatting settings.[br] +## [br] +## [param message] The text to print. +func print_message(message: String) -> GdUnitMessageWriter: + _print_message(message, _current_color, _current_indent, _current_flags) + reset() + return self + + +## Prints a message with current formatting settings followed by a newline.[br] +## [br] +## [param message] The text to print. +func println_message(message: String) -> GdUnitMessageWriter: + _println_message(message, _current_color, _current_indent, _current_flags) + reset() + return self + + +## Prints a message at a specific column position with current formatting settings.[br] +## [br] +## [param message] The text to print.[br] +## [param cursor_pos] The column position where the text should start. +func print_at(message: String, cursor_pos: int) -> void: + _print_at(message, cursor_pos, _current_color, _current_effect, _current_align, _current_flags) + reset() + + +## Prints a stack trace with the current indentation setting.[br] +## [br] +## [param stack_trace] The stack trace to print. +func print_stack_trace(stack_trace: GdUnitStackTrace) -> void: + if stack_trace: + _print_stack_trace(stack_trace, _current_indent) + + +## Internal implementation of print_stack_trace.[br] +## [br] +## To be overridden by concrete formatters.[br] +## [br] +## [param stack_trace] The stack trace to print.[br] +## [param current_indent] The indentation level. +@abstract func _print_stack_trace(stack_trace: GdUnitStackTrace, current_indent: int) -> void + + +## Internal implementation of print_message.[br] +## [br] +## To be overridden by concrete formatters.[br] +## [br] +## [param _message] The text to print.[br] +## [param _color] The color to use.[br] +## [param _indent] The indentation level.[br] +## [param _flags] The style flags to apply. +@abstract func _print_message(_message: String, _color: Color, _indent: int, _flags: int) -> void + + +## Internal implementation of println_message.[br] +## [br] +## To be overridden by concrete formatters.[br] +## [br] +## [param _message] The text to print.[br] +## [param _color] The color to use.[br] +## [param _indent] The indentation level.[br] +## [param _flags] The style flags to apply. +@abstract func _println_message(_message: String, _color: Color, _indent: int, _flags: int) -> void + + +## Internal implementation of print_at.[br] +## [br] +## To be overridden by concrete formatters.[br] +## [br] +## [param _message] The text to print.[br] +## [param _cursor_pos] The column position.[br] +## [param _color] The color to use.[br] +## [param _effect] The effect to apply.[br] +## [param _align] The text alignment.[br] +## [param _flags] The style flags to apply. +@abstract func _print_at(_message: String, _cursor_pos: int, _color: Color, _effect: GdUnitMessageWriter.Effect, _align: Align, _flags: int) -> void + + +## Clears all output content.[br] +## [br] +## To be overridden by concrete formatters. +@abstract func clear() -> void diff --git a/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid new file mode 100644 index 0000000..4ef73dc --- /dev/null +++ b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid @@ -0,0 +1 @@ +uid://7coiiyls0483 diff --git a/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd new file mode 100644 index 0000000..72de675 --- /dev/null +++ b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd @@ -0,0 +1,129 @@ +@tool +class_name GdUnitRichTextMessageWriter +extends GdUnitMessageWriter +## A message writer implementation using [RichTextLabel] for the test report UI.[br] +## [br] +## This writer implementation writes formatted messages to a [RichTextLabel] using BBCode.[br] +## It supports:[br] +## - Text formatting using BBCode (bold, italic, underline)[br] +## - Text coloring using push colors[br] +## - Text indentation using push indent[br] +## - Text effects like wave[br] +## - Basic cursor positioning[br] +## [br] +## Used to format test reports in the editor UI. + + +## The [RichTextLabel] instance to write formatted messages +var _output: RichTextLabel +var _report_formatter: GdUnitReportPanel + +## Tracks current position in characters from line start +var _current_pos := 0 + + +## Creates a new message writer for the given [RichTextLabel].[br] +## [br] +## [param output] The [RichTextLabel] used for output. +func _init(output: RichTextLabel) -> void: + _output = output + _report_formatter = GdUnitReportPanel.new() + + +## Applies text style flags by wrapping text in BBCode tags.[br] +## [br] +## Available styles:[br] +## - BOLD: [b]text[/b][br] +## - ITALIC: [i]text[/i][br] +## - UNDERLINE: [u]text[/u][br] +## [br] +## [param message] The text to format.[br] +## [param flags] The text style flags to apply. +func _apply_flags(message: String, flags: int) -> String: + if flags & BOLD: + message = "[b]%s[/b]" % message + if flags & ITALIC: + message = "[i]%s[/i]" % message + if flags & UNDERLINE: + message = "[u]%s[/u]" % message + return message + + +## Internal implementation of print_stack_trace.[br] +## [br] +## [param stack_trace] The stack trace to print.[br] +## [param _indent] The indentation level. +func _print_stack_trace(stack_trace: GdUnitStackTrace, _indent: int) -> void: + _report_formatter.add_stack_trace(_output, stack_trace) + + +## Writes a message with formatting.[br] +## [br] +## [param message] The text to write.[br] +## [param _color] The color to use.[br] +## [param _indent] The indentation level.[br] +## [param flags] The text style flags to apply. +func _print_message(message: String, _color: Color, _indent: int, flags: int) -> void: + _output.push_color(_color) + message = _apply_flags(message, flags) + _output.append_text(message) + _output.pop() + _current_pos += _indent * 2 + message.length() + + +## Writes a message with formatting followed by a line break.[br] +## [br] +## [param message] The text to write.[br] +## [param _color] The color to use.[br] +## [param _indent] The indentation level.[br] +## [param flags] The text style flags to apply. +func _println_message(message: String, _color: Color, _indent: int, flags: int) -> void: + _print_message(message, _color, _indent, flags) + _output.newline() + _current_pos = 0 + + +## Writes a message at a specific column position.[br] +## [br] +## [param message] The text to write.[br] +## [param cursor_pos] The column position from line start.[br] +## [param _color] The color to use.[br] +## [param _effect] The text effect to apply (e.g. wave).[br] +## [param _align] The text alignment (left or right).[br] +## [param flags] The text style flags to apply. +func _print_at(message: String, cursor_pos: int, _color: Color, _effect: GdUnitMessageWriter.Effect, _align: Align, flags: int) -> void: + if _align == Align.RIGHT: + cursor_pos = cursor_pos - message.length() + + var spaces := cursor_pos - _current_pos + if spaces > 0: + _output.append_text("".lpad(spaces)) + _current_pos += spaces + else: + _output.append_text(" ") + _current_pos += 1 + + _output.push_color(_color) + message = _apply_flags(message, flags) + match _effect: + Effect.NONE: + pass + Effect.WAVE: + message = "[wave]%s[/wave]" % message + _output.append_text(message) + _output.pop() + _current_pos += message.length() + + +## Clears all written content from the [RichTextLabel]. +func clear() -> void: + _output.clear() + _current_pos = 0 + + +func indent(value: int) -> GdUnitMessageWriter: + if value < 0: + _output.pop() + else: + _output.push_indent(1) + return self diff --git a/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid new file mode 100644 index 0000000..335da20 --- /dev/null +++ b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid @@ -0,0 +1 @@ +uid://b8o5uwd7afci2 diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs new file mode 100644 index 0000000..096e83c --- /dev/null +++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs @@ -0,0 +1,235 @@ +// Copyright (c) 2025 Mike Schulze +// MIT License - See LICENSE file in the repository root for full license text +#pragma warning disable IDE1006 +namespace gdUnit4.addons.gdUnit4.src.dotnet; +#pragma warning restore IDE1006 + +#if GDUNIT4NET_API_V5 +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using GdUnit4; +using GdUnit4.Api; + +using Godot; +using Godot.Collections; + +/// +/// The GdUnit4 GDScript - C# API wrapper. +/// +public partial class GdUnit4CSharpApi : RefCounted +{ + /// + /// The signal to be emitted when the execution is completed. + /// + [Signal] +#pragma warning disable CA1711 + public delegate void ExecutionCompletedEventHandler(); +#pragma warning restore CA1711 + +#pragma warning disable CA2213, SA1201 + private CancellationTokenSource? executionCts; +#pragma warning restore CA2213, SA1201 + + /// + /// Indicates if the API loaded. + /// + /// Returns true if the API already loaded. + public static bool IsApiLoaded() + => true; + + /// + /// Runs test discovery on the given script. + /// + /// The script to be scanned. + /// The list of tests discovered as dictionary. + public static Array DiscoverTests(CSharpScript sourceScript) + { + try + { + // Get the list of test case descriptors from the API + var testCaseDescriptors = GdUnit4NetApiGodotBridge.DiscoverTestsFromScript(sourceScript); + + // Convert each TestCaseDescriptor to a Dictionary + return testCaseDescriptors + .Select(descriptor => new Dictionary + { + ["guid"] = descriptor.Id.ToString(), + ["managed_type"] = descriptor.ManagedType, + ["test_name"] = descriptor.ManagedMethod, + ["source_file"] = sourceScript.ResourcePath, + ["line_number"] = descriptor.LineNumber, + ["attribute_index"] = descriptor.AttributeIndex, + ["require_godot_runtime"] = descriptor.RequireRunningGodotEngine, + ["code_file_path"] = descriptor.CodeFilePath ?? string.Empty, + ["simple_name"] = descriptor.SimpleName, + ["fully_qualified_name"] = descriptor.FullyQualifiedName, + ["assembly_location"] = descriptor.AssemblyPath + }) + .Aggregate( + new Array(), + (array, dict) => + { + array.Add(dict); + return array; + }); + } +#pragma warning disable CA1031 + catch (Exception e) +#pragma warning restore CA1031 + { + GD.PrintErr($"Error discovering tests: {e.Message}\n{e.StackTrace}"); +#pragma warning disable IDE0028 // Do not catch general exception types + return new Array(); +#pragma warning restore IDE0028 // Do not catch general exception types + } + } + + /// + /// Creates a test suite based on the specified source path and line number. + /// + /// The path to the source file from which to create the test suite. + /// The line number in the source file where the method to test is defined. + /// The path where the test suite should be created. + /// A dictionary containing information about the created test suite. + public static Dictionary CreateTestSuite(string sourcePath, int lineNumber, string testSuitePath) + => GdUnit4NetApiGodotBridge.CreateTestSuite(sourcePath, lineNumber, testSuitePath); + + /// + /// Gets the version of the GdUnit4 assembly. + /// + /// The version string of the GdUnit4 assembly. + public static string Version() + => GdUnit4NetApiGodotBridge.Version(); + + /// + public override void _Notification(int what) + { + if (what != NotificationPredelete) + return; + executionCts?.Dispose(); + executionCts = null; + } + + /// + /// Executes the tests and using the listener for reporting the results. + /// + /// A list of tests to be executed. + /// The listener to report the results. + public void ExecuteAsync(Array tests, Callable listener) + { + try + { + // Cancel any ongoing execution + executionCts?.Cancel(); + executionCts?.Dispose(); + + // Create new cancellation token source + executionCts = new CancellationTokenSource(); + + Debug.Assert(tests != null, nameof(tests) + " != null"); + var testSuiteNodes = new List { BuildTestSuiteNodeFrom(tests) }; + GdUnit4NetApiGodotBridge.ExecuteAsync(testSuiteNodes, listener, executionCts.Token) + .GetAwaiter() + .OnCompleted(() => EmitSignal(SignalName.ExecutionCompleted)); + } +#pragma warning disable CA1031 + catch (Exception e) +#pragma warning restore CA1031 + { + GD.PrintErr($"Error executing tests: {e.Message}\n{e.StackTrace}"); + Task.Run(() => { }).GetAwaiter().OnCompleted(() => EmitSignal(SignalName.ExecutionCompleted)); + } + } + + /// + /// Will cancel the current test execution. + /// + public void CancelExecution() + { + try + { + executionCts?.Cancel(); + } +#pragma warning disable CA1031 + catch (Exception e) +#pragma warning restore CA1031 + { + GD.PrintErr($"Error cancelling execution: {e.Message}"); + } + } + + // Convert a set of Tests stored as Dictionaries to TestSuiteNode + // all tests are assigned to a single test suit + internal static TestSuiteNode BuildTestSuiteNodeFrom(Array tests) + { + if (tests.Count == 0) + throw new InvalidOperationException("Cant build 'TestSuiteNode' from an empty test set."); + + // Create a suite ID + var suiteId = Guid.NewGuid(); + var firstTest = tests[0]; + var managedType = firstTest["managed_type"].AsString(); + var assemblyLocation = firstTest["assembly_location"].AsString(); + var sourceFile = firstTest["source_file"].AsString(); + + // Create TestCaseNodes for each test in the suite + var testCaseNodes = tests + .Select(test => new TestCaseNode + { + Id = Guid.Parse(test["guid"].AsString()), + ParentId = suiteId, + ManagedMethod = test["test_name"].AsString(), + LineNumber = test["line_number"].AsInt32(), + AttributeIndex = test["attribute_index"].AsInt32(), + RequireRunningGodotEngine = test["require_godot_runtime"].AsBool() + }) + .ToList(); + + return new TestSuiteNode + { + Id = suiteId, + ParentId = Guid.Empty, + ManagedType = managedType, + AssemblyPath = assemblyLocation, + SourceFile = sourceFile, + Tests = testCaseNodes + }; + } +} +#else +using Godot; +using Godot.Collections; + +public partial class GdUnit4CSharpApi : RefCounted +{ + [Signal] + public delegate void ExecutionCompletedEventHandler(); + + public static bool IsApiLoaded() + { + GD.PushWarning("No `gdunit4.api` dependency found, check your project dependencies."); + return false; + } + + + public static string Version() + => "Unknown"; + + public static Array DiscoverTests(CSharpScript sourceScript) => new(); + + public void ExecuteAsync(Array tests, Callable listener) + { + } + + public static bool IsTestSuite(CSharpScript script) + => false; + + public static Dictionary CreateTestSuite(string sourcePath, int lineNumber, string testSuitePath) + => new(); +} +#endif diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs.uid b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs.uid new file mode 100644 index 0000000..8d3355e --- /dev/null +++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs.uid @@ -0,0 +1 @@ +uid://bu436njjeyex1 diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd new file mode 100644 index 0000000..3d8ba25 --- /dev/null +++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd @@ -0,0 +1,114 @@ +## GdUnit4CSharpApiLoader +## +## A bridge class that handles communication between GDScript and C# for the GdUnit4 testing framework. +## This loader acts as a compatibility layer to safely access the .NET API and ensure that calls +## only proceed when the .NET environment is properly configured and available. +## [br] +## The class handles: +## - Verification of .NET runtime availability +## - Loading the C# wrapper script +## - Checking for the GdUnit4Api assembly +## - Providing proxy methods to access GdUnit4 functionality in C# +@static_unload +class_name GdUnit4CSharpApiLoader +extends RefCounted + +## Cached reference to the loaded C# wrapper script +static var _gdUnit4NetWrapper: Script + +## Cached instance of the API (singleton pattern) +static var _api_instance: RefCounted + + +class TestEventListener extends RefCounted: + + func publish_event(event: Dictionary) -> void: + var test_event := GdUnitEvent.new().deserialize(event) + GdUnitSignals.instance().gdunit_event.emit(test_event) + +static var _test_event_listener := TestEventListener.new() + + +## Returns an instance of the GdUnit4CSharpApi wrapper.[br] +## @return Script: The loaded C# wrapper or null if .NET is not supported +static func instance() -> Script: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return null + + return _gdUnit4NetWrapper + + +## Returns or creates a single instance of the API [br] +## This improves performance by reusing the same object +static func api_instance() -> RefCounted: + if _api_instance == null and is_api_loaded(): + @warning_ignore("unsafe_method_access") + _api_instance = instance().new() + return _api_instance + + +static func is_engine_version_supported(engine_version: int = Engine.get_version_info().hex) -> bool: + return engine_version >= 0x40200 + + +## Checks if the .NET environment is properly configured and available.[br] +## @return bool: True if .NET is fully supported and the assembly is found +static func is_api_loaded() -> bool: + # If the wrapper is already loaded we don't need to check again + if _gdUnit4NetWrapper != null: + return true + + # First we check if this is a Godot .NET runtime instance + if not ClassDB.class_exists("CSharpScript") or not is_engine_version_supported(): + return false + # Second we check the C# project file exists + var assembly_name: String = ProjectSettings.get_setting("dotnet/project/assembly_name") + if assembly_name.is_empty() or not FileAccess.file_exists("res://%s.csproj" % assembly_name): + return false + + # Finally load the wrapper and check if the GdUnit4 assembly can be found + _gdUnit4NetWrapper = load("res://addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs") + @warning_ignore("unsafe_method_access") + return _gdUnit4NetWrapper.call("IsApiLoaded") + + +## Returns the version of the GdUnit4 .NET assembly.[br] +## @return String: The version string or "unknown" if .NET is not supported +static func version() -> String: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return "unknown" + @warning_ignore("unsafe_method_access") + return instance().Version() + + +static func discover_tests(source_script: Script) -> Array[GdUnitTestCase]: + var tests: Array = _gdUnit4NetWrapper.call("DiscoverTests", source_script) + + return Array(tests.map(GdUnitTestCase.from_dict), TYPE_OBJECT, "RefCounted", GdUnitTestCase) + + +static func execute(tests: Array[GdUnitTestCase]) -> void: + var net_api := api_instance() + if net_api == null: + push_warning("Execute C# tests not supported!") + return + var tests_as_dict: Array[Dictionary] = Array(tests.map(GdUnitTestCase.to_dict), TYPE_DICTIONARY, "", null) + + net_api.call("ExecuteAsync", tests_as_dict, _test_event_listener.publish_event) + @warning_ignore("unsafe_property_access") + await net_api.ExecutionCompleted + + +static func create_test_suite(source_path: String, line_number: int, test_suite_path: String) -> GdUnitResult: + if not GdUnit4CSharpApiLoader.is_api_loaded(): + return GdUnitResult.error("Can't create test suite. No .NET support found.") + @warning_ignore("unsafe_method_access") + var result: Dictionary = instance().CreateTestSuite(source_path, line_number, test_suite_path) + if result.has("error"): + return GdUnitResult.error(str(result.get("error"))) + return GdUnitResult.success(result) + + +static func is_csharp_file(resource_path: String) -> bool: + var ext := resource_path.get_extension() + return ext == "cs" and GdUnit4CSharpApiLoader.is_api_loaded() diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid new file mode 100644 index 0000000..45dcbaa --- /dev/null +++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid @@ -0,0 +1 @@ +uid://dcoijry7ikk42 diff --git a/addons/gdUnit4/src/doubler/CallableDoubler.gd b/addons/gdUnit4/src/doubler/CallableDoubler.gd new file mode 100644 index 0000000..1bc78a1 --- /dev/null +++ b/addons/gdUnit4/src/doubler/CallableDoubler.gd @@ -0,0 +1,156 @@ +## The helper class to allow to double Callable +## Is just a wrapper to the original callable with the same function signature. +## +## Due to interface conflicts between 'Callable' and 'Object', +## it is not possible to stub the 'call' and 'call_deferred' methods. +## +## The Callable interface and the Object class have overlapping method signatures, +## which causes conflicts when attempting to stub these methods. +## As a result, you cannot create stubs for 'call' and 'call_deferred' methods. + +class_name CallableDoubler + + +const doubler_script :Script = preload("res://addons/gdUnit4/src/doubler/CallableDoubler.gd") + +var _cb: Callable + + +func _init(cb: Callable) -> void: + assert(cb!=null, "Invalid argument must not be null") + _cb = cb + +## --- helpers ----------------------------------------------------------------------------------------------------------------------------- +static func map_func_name(method_info: Dictionary) -> String: + return method_info["name"] + + +## We do not want to double all functions based on Object for this class +## Is used on SpyBuilder to excluding functions to be doubled for Callable +static func excluded_functions() -> PackedStringArray: + return ClassDB.class_get_method_list("Object")\ + .map(CallableDoubler.map_func_name)\ + .filter(func (name: String) -> bool: + return !CallableDoubler.callable_functions().has(name)) + + +static func non_callable_functions(name: String) -> bool: + return ![ + # we allow "_init", is need to construct it, + "excluded_functions", + "non_callable_functions", + "callable_functions", + "map_func_name" + ].has(name) + + +## Returns the list of supported Callable functions +static func callable_functions() -> PackedStringArray: + var supported_functions :Array = doubler_script.get_script_method_list()\ + .map(CallableDoubler.map_func_name)\ + .filter(CallableDoubler.non_callable_functions) + # We manually add these functions that we cannot/may not overwrite in this class + supported_functions.append_array(["call_deferred", "callv"]) + return supported_functions + + +## ----------------------------------------------------------------------------------------------------------------------------------------- +## Callable functions stubing +## ----------------------------------------------------------------------------------------------------------------------------------------- + +func bind(...varargs: Array) -> Callable: + _cb = _cb.bindv(varargs) + return _cb + + +func bindv(caller_args: Array) -> Callable: + _cb = _cb.bindv(caller_args) + return _cb + + +@warning_ignore("native_method_override") +func call(...varargs: Array) -> Variant: + return _cb.callv(varargs) + + +# Is not supported, see class description +#func call_deferred(...varargs: Array) -> void: +# return _cb.call_deferred(varargs) + + +# Is not supported, see class description +#func callv(arguments: Array) -> Variant: +# return _cb.callv(arguments) + + +func get_bound_arguments() -> Array: + return _cb.get_bound_arguments() + + +func get_bound_arguments_count() -> int: + return _cb.get_bound_arguments_count() + + +func get_method() -> StringName: + return _cb.get_method() + + +func get_object() -> Object: + return _cb.get_object() + + +func get_object_id() -> int: + return _cb.get_object_id() + + +func hash() -> int: + return _cb.hash() + + +func is_custom() -> bool: + return _cb.is_custom() + + +func is_null() -> bool: + return _cb.is_null() + + +func is_standard() -> bool: + return _cb.is_standard() + + +func is_valid() -> bool: + return _cb.is_valid() + + +func rpc(...varargs: Array) -> void: + match varargs.size(): + 0: _cb.rpc() + 1: _cb.rpc(varargs[0]) + 2: _cb.rpc(varargs[0], varargs[1]) + 3: _cb.rpc(varargs[0], varargs[1], varargs[2]) + 4: _cb.rpc(varargs[0], varargs[1], varargs[2], varargs[3], varargs[4]) + 5: _cb.rpc(varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5]) + 6: _cb.rpc(varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6]) + 7: _cb.rpc(varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6], varargs[7]) + 8: _cb.rpc(varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6], varargs[7], varargs[8]) + 9: _cb.rpc(varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6], varargs[7], varargs[8], varargs[9]) + + +@warning_ignore("untyped_declaration") +func rpc_id(peer_id: int, ...varargs: Array) -> void: + match varargs.size(): + 0: _cb.rpc_id(peer_id ) + 1: _cb.rpc_id(peer_id, varargs[0]) + 2: _cb.rpc_id(peer_id, varargs[0], varargs[1]) + 3: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2]) + 4: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2], varargs[3], varargs[4]) + 5: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5]) + 6: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6]) + 7: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6], varargs[7]) + 8: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6], varargs[7], varargs[8]) + 9: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6], varargs[7], varargs[8], varargs[9]) + +func unbind(argcount: int) -> Callable: + _cb = _cb.unbind(argcount) + return _cb diff --git a/addons/gdUnit4/src/doubler/CallableDoubler.gd.uid b/addons/gdUnit4/src/doubler/CallableDoubler.gd.uid new file mode 100644 index 0000000..239d1f2 --- /dev/null +++ b/addons/gdUnit4/src/doubler/CallableDoubler.gd.uid @@ -0,0 +1 @@ +uid://kq73lnvfvy1s diff --git a/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd b/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd new file mode 100644 index 0000000..d9459dc --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd @@ -0,0 +1,4 @@ +@abstract class_name GdFunctionDoubler +extends RefCounted + +@abstract func double(func_descriptor: GdFunctionDescriptor) -> PackedStringArray diff --git a/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd.uid b/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd.uid new file mode 100644 index 0000000..be1fdf2 --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd.uid @@ -0,0 +1 @@ +uid://c4tvvffgijnnd diff --git a/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd b/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd new file mode 100644 index 0000000..31aa06b --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd @@ -0,0 +1,119 @@ +# A class doubler used to mock and spy checked implementations +class_name GdUnitClassDoubler +extends RefCounted + +const DOUBLER_INSTANCE_ID_PREFIX := "gdunit_doubler_instance_id_" +const EXCLUDE_VIRTUAL_FUNCTIONS = [ + # we have to exclude notifications because NOTIFICATION_PREDELETE is try + # to delete already freed spy/mock resources and will result in a conflict + "_notification", + "notification", + # https://github.com/godotengine/godot/issues/67461 + "get_name", + "get_path", + "duplicate", + ] +# define functions to be exclude when spy or mock checked a scene +const EXLCUDE_SCENE_FUNCTIONS = [ + # needs to exclude get/set script functions otherwise it endsup in recursive endless loop + "set_script", + "get_script", + # needs to exclude otherwise verify fails checked collection arguments checked calling to string + "_to_string", +] +const EXCLUDE_FUNCTIONS = ["new", "free", "get_instance_id", "get_tree"] + + +static func check_leaked_instances() -> void: + ## we check that all registered spy/mock instances are removed from the engine meta data + for key in Engine.get_meta_list(): + if key.begins_with(DOUBLER_INSTANCE_ID_PREFIX): + var instance: Variant = Engine.get_meta(key) + push_error("GdUnit internal error: an spy/mock instance '%s', class:'%s' is not removed from the engine and will lead in a leaked instance!" % [instance, instance.__SOURCE_CLASS]) + await (Engine.get_main_loop() as SceneTree).process_frame + + +# loads the doubler template +# class_info = { "class_name": <>, "class_path" : <>} +static func load_template(template: String, class_info: Dictionary) -> PackedStringArray: + var clazz_name: String = class_info.get("class_name") + var source_code := template\ + .replace("${source_class}", clazz_name)\ + # Replace template class_name DoubledClass with source class name + .replace("SourceClassName", clazz_name.replace(".", "_")) + var lines := GdScriptParser.to_unix_format(source_code).split("\n") + @warning_ignore("return_value_discarded") + lines.insert(1, extends_clazz(class_info)) + lines.insert(0, "@warning_ignore_start('unsafe_call_argument', 'shadowed_variable', 'untyped_declaration', 'native_method_override', 'int_as_enum_without_cast')") + return lines + + +static func extends_clazz(class_info: Dictionary) -> String: + var clazz_name: String = class_info.get("class_name") + var clazz_path: PackedStringArray = class_info.get("class_path", []) + # is inner class? + if clazz_path.size() > 1: + return "extends %s" % clazz_name + if clazz_path.size() == 1 and clazz_path[0].ends_with(".gd"): + return "extends '%s'" % clazz_path[0] + return "extends %s" % clazz_name + + +# double all functions of given instance +static func double_functions(instance: Object, clazz_name: String, clazz_path: PackedStringArray, func_doubler: GdFunctionDoubler, exclude_functions: Array) -> PackedStringArray: + var doubled_source := PackedStringArray() + var parser := GdScriptParser.new() + var exclude_override_functions := EXCLUDE_VIRTUAL_FUNCTIONS + EXCLUDE_FUNCTIONS + exclude_functions + var functions := Array() + + # double script functions + if not ClassDB.class_exists(clazz_name): + var result := parser.parse(clazz_name, clazz_path) + if result.is_error(): + push_error(result.error_message()) + return PackedStringArray() + var class_descriptor: GdClassDescriptor = result.value() + for func_descriptor in class_descriptor.functions(): + if instance != null and not instance.has_method(func_descriptor.name()): + #prints("no virtual func implemented",clazz_name, func_descriptor.name() ) + continue + if functions.has(func_descriptor.name()) or exclude_override_functions.has(func_descriptor.name()): + continue + doubled_source += func_doubler.double(func_descriptor) + functions.append(func_descriptor.name()) + + # double regular class functions + var clazz_functions := GdObjects.extract_class_functions(clazz_name, clazz_path) + for method: Dictionary in clazz_functions: + var func_descriptor := GdFunctionDescriptor.extract_from(method) + # exclude private core functions + if func_descriptor.is_private(): + continue + if functions.has(func_descriptor.name()) or exclude_override_functions.has(func_descriptor.name()): + continue + # GD-110: Hotfix do not double invalid engine functions + if is_invalid_method_descriptior(method): + #prints("'%s': invalid method descriptor found! %s" % [clazz_name, method]) + continue + # do not double on not implemented virtual functions + if instance != null and not instance.has_method(func_descriptor.name()): + #prints("no virtual func implemented",clazz_name, func_descriptor.name() ) + continue + functions.append(func_descriptor.name()) + doubled_source.append_array(func_doubler.double(func_descriptor)) + return doubled_source + + +# GD-110 +static func is_invalid_method_descriptior(method: Dictionary) -> bool: + var return_info: Dictionary = method["return"] + var type: int = return_info["type"] + var usage: int = return_info["usage"] + var clazz_name: String = return_info["class_name"] + # is method returning a type int with a given 'class_name' we have an enum + # and the PROPERTY_USAGE_CLASS_IS_ENUM must be set + if type == TYPE_INT and not clazz_name.is_empty() and not (usage & PROPERTY_USAGE_CLASS_IS_ENUM): + return true + if clazz_name == "Variant.Type": + return true + return false diff --git a/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd.uid b/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd.uid new file mode 100644 index 0000000..333232f --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd.uid @@ -0,0 +1 @@ +uid://bxwneepd3jikn diff --git a/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd b/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd new file mode 100644 index 0000000..a1a75f1 --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd @@ -0,0 +1,336 @@ +class_name GdUnitFunctionDoublerBuilder +extends RefCounted + +const TYPE_VOID = GdObjects.TYPE_VOID +const TYPE_VARIANT = GdObjects.TYPE_VARIANT +const TYPE_VARARG = GdObjects.TYPE_VARARG +const TYPE_FUNC = GdObjects.TYPE_FUNC +const TYPE_FUZZER = GdObjects.TYPE_FUZZER +const TYPE_ENUM = GdObjects.TYPE_ENUM + +const DEFAULT_TYPED_RETURN_VALUES := { + TYPE_NIL: "null", + TYPE_BOOL: "false", + TYPE_INT: "0", + TYPE_FLOAT: "0.0", + TYPE_STRING: "\"\"", + TYPE_STRING_NAME: "&\"\"", + TYPE_VECTOR2: "Vector2.ZERO", + TYPE_VECTOR2I: "Vector2i.ZERO", + TYPE_RECT2: "Rect2()", + TYPE_RECT2I: "Rect2i()", + TYPE_VECTOR3: "Vector3.ZERO", + TYPE_VECTOR3I: "Vector3i.ZERO", + TYPE_VECTOR4: "Vector4.ZERO", + TYPE_VECTOR4I: "Vector4i.ZERO", + TYPE_TRANSFORM2D: "Transform2D()", + TYPE_PLANE: "Plane()", + TYPE_QUATERNION: "Quaternion()", + TYPE_AABB: "AABB()", + TYPE_BASIS: "Basis()", + TYPE_TRANSFORM3D: "Transform3D()", + TYPE_PROJECTION: "Projection()", + TYPE_COLOR: "Color()", + TYPE_NODE_PATH: "NodePath()", + TYPE_RID: "RID()", + TYPE_OBJECT: "null", + TYPE_CALLABLE: "Callable()", + TYPE_SIGNAL: "Signal()", + TYPE_DICTIONARY: "Dictionary()", + TYPE_ARRAY: "Array()", + TYPE_PACKED_BYTE_ARRAY: "PackedByteArray()", + TYPE_PACKED_INT32_ARRAY: "PackedInt32Array()", + TYPE_PACKED_INT64_ARRAY: "PackedInt64Array()", + TYPE_PACKED_FLOAT32_ARRAY: "PackedFloat32Array()", + TYPE_PACKED_FLOAT64_ARRAY: "PackedFloat64Array()", + TYPE_PACKED_STRING_ARRAY: "PackedStringArray()", + TYPE_PACKED_VECTOR2_ARRAY: "PackedVector2Array()", + TYPE_PACKED_VECTOR3_ARRAY: "PackedVector3Array()", + TYPE_PACKED_VECTOR4_ARRAY: "PackedVector4Array()", + TYPE_PACKED_COLOR_ARRAY: "PackedColorArray()", + GdObjects.TYPE_VARIANT: "null", + GdObjects.TYPE_ENUM: "0" +} + + +# @GlobalScript enums +# needs to manually map because of https://github.com/godotengine/godot/issues/73835 +const DEFAULT_ENUM_RETURN_VALUES = { + "Side" : "SIDE_LEFT", + "Corner" : "CORNER_TOP_LEFT", + "Orientation" : "HORIZONTAL", + "ClockDirection" : "CLOCKWISE", + "HorizontalAlignment" : "HORIZONTAL_ALIGNMENT_LEFT", + "VerticalAlignment" : "VERTICAL_ALIGNMENT_TOP", + "InlineAlignment" : "INLINE_ALIGNMENT_TOP_TO", + "EulerOrder" : "EULER_ORDER_XYZ", + "Key" : "KEY_NONE", + "KeyModifierMask" : "KEY_CODE_MASK", + "MouseButton" : "MOUSE_BUTTON_NONE", + "MouseButtonMask" : "MOUSE_BUTTON_MASK_LEFT", + "JoyButton" : "JOY_BUTTON_INVALID", + "JoyAxis" : "JOY_AXIS_INVALID", + "MIDIMessage" : "MIDI_MESSAGE_NONE", + "Error" : "OK", + "PropertyHint" : "PROPERTY_HINT_NONE", + "Variant.Type" : "TYPE_NIL", + "Vector2.Axis" : "Vector2.AXIS_X", + "Vector2i.Axis" : "Vector2i.AXIS_X", + "Vector3.Axis" : "Vector3.AXIS_X", + "Vector3i.Axis" : "Vector3i.AXIS_X", + "Vector4.Axis" : "Vector4.AXIS_X", + "Vector4i.Axis" : "Vector4i.AXIS_X", +} + + +static var def_constructor := """ + func _init({constructor_args}) -> void: + __init_doubler() + super({args}) + """.dedent() + + +static var def_verify_block := """ + # verify block + var __verifier := __get_verifier() + if __verifier != null: + if __verifier.is_verify_interactions(): + __verifier.verify_interactions("{func_name}", __args) + {default_return} + else: + __verifier.save_function_interaction("{func_name}", __args) + """.dedent().indent("\t").trim_suffix("\n") + + +static var def_prepare_block := """ + if __is_prepare_return_value(): + __save_function_return_value("{func_name}", __args) + {default_return} + """.dedent().indent("\t").trim_suffix("\n") + + +static var def_void_prepare_block := """ + if __is_prepare_return_value(): + push_error("Mocking functions with return type void is not allowed!") + return + """.dedent().indent("\t").trim_suffix("\n") + + +static var def_mock_return := """ + if __is_do_not_call_real_func("{func_name}", __args): + return __return_mock_value("{func_name}", __args, {default_return}) + """.dedent().indent("\t").trim_suffix("\n") + + +static var def_void_mock_return := """ + if __is_do_not_call_real_func("{func_name}", __args): + return + """.dedent().indent("\t").trim_suffix("\n") + + +var fd: GdFunctionDescriptor +var func_args: Array +var default_return: String +var verify_block: String = "" +var prepare_block: String = "" +var mock_return: String = "" + + +func _init(descriptor: GdFunctionDescriptor) -> void: + # verify all default types are covered + for type_key in TYPE_MAX: + if not DEFAULT_TYPED_RETURN_VALUES.has(type_key): + push_error("missing default definitions! Expexting %d bud is %d" % [DEFAULT_TYPED_RETURN_VALUES.size(), TYPE_MAX]) + prints("missing default definition for type", type_key) + assert(DEFAULT_TYPED_RETURN_VALUES.has(type_key), "Missing Type default definition!") + + fd = descriptor + func_args = argument_names() + default_return = default_return_value() + + +func build_func_signature() -> String: + var return_type := ":" if fd._return_type == TYPE_VARIANT else " -> %s:" % fd.return_type_as_string() + return "{static}func {func_name}({args}){return_type}".format({ + "static" : "static " if fd.is_static() else "", + "func_name": fd.name(), + "args": arguments_full_quilified(), + "return_type": return_type + }) + + +func arguments_full_quilified() -> String: + var collect := PackedStringArray() + for arg in fd.args(): + var name := argument_name(arg) + if arg.has_default(): + var signature := "{argument_name}{arg_typed}={arg_value}".format({ + "argument_name" : name, + "arg_typed" : ":"+GdObjects.type_as_string(arg.type()) if arg.type() == GdObjects.TYPE_VARIANT else "", + "arg_value" : arg.value_as_string() + }) + collect.push_back(signature) + else: + collect.push_back(name) + if fd.is_vararg(): + var arg_descriptor := fd.varargs()[0] + collect.push_back("...%s_: Array" % arg_descriptor.name()) + return ", ".join(collect) + + +func argument_name(arg: GdFunctionArgument) -> String: + return arg.name() + "_" + + +func argument_names() -> PackedStringArray: + return fd.args().map(argument_name) + + +func argument_default(arg :GdFunctionArgument) -> String: + return (arg.value_as_string() + if arg.has_default() + else DEFAULT_TYPED_RETURN_VALUES.get(arg.type(), "null")) + + +func build_constructor_arguments() -> String: + var arguments := PackedStringArray() + for arg in fd.args(): + var default_value := argument_default(arg) + var arg_signature := "{name}:{type}={default}".format({ + "name" : argument_name(arg), + "type" : "Variant" if default_value == "null" else "", + "default" : default_value + }) + arguments.append(arg_signature) + if fd.is_vararg(): + arguments.append("...varargs: Array") + return ", ".join(arguments) + + +func build_arguments() -> String: + return "\tvar __args := [{args}]{varargs}".format({ + "args" : ", ".join(func_args), + "varargs" : " + varargs_" if fd.is_vararg() else "" + }) + + +func build_super_calls() -> String: + if !fd.is_vararg(): + return 'super(%s)\n' % ", ".join(func_args) + + var match_block := "match varargs_.size():\n" + for index in range(0, 11): + match_block += '{index}: super({args})\n'.format({ + "index" : index, + "args" : ", ".join(func_args + build_vararg_list(index)) + }).indent("\t") + match_block += '_: push_error("To many varradic arguments.")\n'.indent("\t") + match_block += "return\n" if is_void_func() else "return %s\n" % default_return + return match_block + + +func build_vararg_list(count: int) -> Array: + var arg_list := [] + for index in count: + arg_list.append("varargs_[%d]" % index) + return arg_list + + +func default_return_value() -> String: + var return_type: Variant = fd.return_type() + if return_type == GdObjects.TYPE_ENUM: + var enum_class := fd._return_class + if DEFAULT_ENUM_RETURN_VALUES.has(enum_class): + return DEFAULT_ENUM_RETURN_VALUES.get(fd._return_class, "0") + + var enum_path := enum_class.split(".") + if enum_path.size() >= 2: + var keys := ClassDB.class_get_enum_constants(enum_path[0], enum_path[1]) + if not keys.is_empty(): + return "%s.%s" % [enum_path[0], keys[0]] + var enum_value: Variant = get_enum_default(enum_class) + if enum_value != null: + return str(enum_value) + # we need fallback for @GlobalScript enums, + return DEFAULT_ENUM_RETURN_VALUES.get(fd._return_class, "0") + return DEFAULT_TYPED_RETURN_VALUES.get(return_type, "invalid") + + +# Determine the enum default by reflection +func get_enum_default(value: String) -> Variant: + var script := GDScript.new() + script.source_code = """ + extends RefCounted + + static func get_enum_default() -> Variant: + return %s.values()[0] + + """.dedent() % value + var err := script.reload() + if err != OK: + push_error("Cant get enum values form '%s', %s" % [value, error_string(err)]) + return 0 + @warning_ignore("unsafe_method_access") + return script.new().call("get_enum_default") + + +func is_void_func() -> bool: + return fd.return_type() == TYPE_NIL or fd.return_type() == TYPE_VOID + + +func with_verify_block() -> GdUnitFunctionDoublerBuilder: + verify_block = def_verify_block.format({ + "func_name" : fd.name(), + "default_return" : "return" if is_void_func() else "return " + default_return + }) + return self + + +func with_prepare_block() -> GdUnitFunctionDoublerBuilder: + if fd.return_type() == TYPE_NIL or fd.return_type() == GdObjects.TYPE_VOID: + prepare_block = def_void_prepare_block + return self + + prepare_block = def_prepare_block.format({ + "func_name" : fd.name(), + "default_return" : "return" if is_void_func() else "return " + default_return + }) + return self + + +func with_mocked_return_value() -> GdUnitFunctionDoublerBuilder: + if is_void_func(): + mock_return = def_void_mock_return.format({ + "func_name" : fd.name(), + }) + else: + mock_return = def_mock_return.format({ + "func_name" : fd.name(), + "default_return" : '"no_arg"' if is_void_func() else default_return + }) + return self + + +func build() -> PackedStringArray: + if fd.name() == "_init": + return [def_constructor.format({ + "constructor_args" : build_constructor_arguments(), + "args" : ", ".join(func_args) + })] + + var func_body: PackedStringArray = [] + func_body.append(build_func_signature()) + func_body.append(build_arguments()) + if not prepare_block.is_empty(): + func_body.append(prepare_block) + func_body.append(verify_block) + if not mock_return.is_empty(): + func_body.append(mock_return) + func_body.append("") + var super_calls := build_super_calls() + if not is_void_func(): + super_calls = super_calls.replace("super(", "return super(" ) + if fd.is_coroutine(): + super_calls = super_calls.replace("super(", "await super(" ) + func_body.append(super_calls.indent("\t")) + return func_body diff --git a/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd.uid b/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd.uid new file mode 100644 index 0000000..6f2833b --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd.uid @@ -0,0 +1 @@ +uid://dvtras25otsk3 diff --git a/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd b/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd new file mode 100644 index 0000000..d735bc5 --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd @@ -0,0 +1,10 @@ +class_name GdUnitMockFunctionDoubler +extends GdFunctionDoubler + + +func double(func_descriptor: GdFunctionDescriptor) -> PackedStringArray: + return GdUnitFunctionDoublerBuilder.new(func_descriptor)\ + .with_prepare_block()\ + .with_verify_block()\ + .with_mocked_return_value()\ + .build() diff --git a/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd.uid b/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd.uid new file mode 100644 index 0000000..6fbe247 --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd.uid @@ -0,0 +1 @@ +uid://brvbr2cp7qum7 diff --git a/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd b/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd new file mode 100644 index 0000000..789eb32 --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd @@ -0,0 +1,53 @@ +class_name GdUnitObjectInteractions +extends RefCounted + + +static func verify(interaction_object: Object, interactions_times: int) -> Variant: + if not _is_mock_or_spy(interaction_object): + return interaction_object + + _get_verifier(interaction_object).do_verify_interactions(interactions_times) + return interaction_object + + +static func verify_no_interactions(interaction_object: Object) -> GdUnitAssert: + var assert_tool := GdUnitAssertImpl.new("") + if not _is_mock_or_spy(interaction_object): + return assert_tool.report_success() + + var summary := _get_verifier(interaction_object).verify_no_interactions() + if summary.is_empty(): + return assert_tool.report_success() + return assert_tool.report_error(GdAssertMessages.error_no_more_interactions(summary)) + + +static func verify_no_more_interactions(interaction_object: Object) -> GdUnitAssert: + var assert_tool := GdUnitAssertImpl.new("") + if not _is_mock_or_spy(interaction_object): + return assert_tool + + var summary := _get_verifier(interaction_object).verify_no_more_interactions() + if summary.is_empty(): + return assert_tool + return assert_tool.report_error(GdAssertMessages.error_no_more_interactions(summary)) + + +static func reset(interaction_object: Object) -> Object: + if not _is_mock_or_spy(interaction_object): + return interaction_object + + _get_verifier(interaction_object).reset_interactions() + return interaction_object + + +static func _is_mock_or_spy(instance: Object) -> bool: + if instance != null and instance.has_method("__get_verifier"): + return true + + push_error("Error: The given object '%s' is not a mock or spy instance!" % instance) + return false + + +static func _get_verifier(interaction_object: Object) -> GdUnitObjectInteractionsVerifier: + @warning_ignore("unsafe_method_access") + return interaction_object.__get_verifier() diff --git a/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd.uid b/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd.uid new file mode 100644 index 0000000..8bf7514 --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd.uid @@ -0,0 +1 @@ +uid://bpld2risvnkpu diff --git a/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd b/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd new file mode 100644 index 0000000..36aa978 --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd @@ -0,0 +1,84 @@ +class_name GdUnitObjectInteractionsVerifier + +var expected_interactions: int = -1 +var saved_interactions := Dictionary() +var verified_interactions := Array() + + +func save_function_interaction(func_name: String, args :Array[Variant]) -> void: + var function_args := [func_name] + args + var matcher := GdUnitArgumentMatchers.to_matcher(function_args, true) + for index in saved_interactions.keys().size(): + var key: Variant = saved_interactions.keys()[index] + if matcher.is_match(key): + saved_interactions[key] += 1 + return + saved_interactions[function_args] = 1 + + +func is_verify_interactions() -> bool: + return expected_interactions != -1 + + +func do_verify_interactions(interactions_times: int = 1) -> void: + expected_interactions = interactions_times + + +func verify_interactions(func_name: String, args: Array[Variant]) -> void: + var summary := Dictionary() + var total_interactions := 0 + var function_args := [func_name] + args + var matcher := GdUnitArgumentMatchers.to_matcher(function_args, true) + for index in saved_interactions.keys().size(): + var key: Variant = saved_interactions.keys()[index] + if matcher.is_match(key): + var interactions: int = saved_interactions.get(key, 0) + total_interactions += interactions + summary[key] = interactions + # add as verified + verified_interactions.append(key) + + var assert_tool := GdUnitAssertImpl.new("") + if total_interactions != expected_interactions: + var __expected_summary := {function_args : expected_interactions} + var error_message: String + # if no interactions macht collect not verified interactions for failure report + if summary.is_empty(): + var __current_summary := verify_no_more_interactions() + error_message = GdAssertMessages.error_validate_interactions(__current_summary, __expected_summary) + else: + error_message = GdAssertMessages.error_validate_interactions(summary, __expected_summary) + @warning_ignore("return_value_discarded") + assert_tool.report_error(error_message) + else: + @warning_ignore("return_value_discarded") + assert_tool.report_success() + expected_interactions = -1 + + +func verify_no_interactions() -> Dictionary: + var summary := Dictionary() + if not saved_interactions.is_empty(): + for index in saved_interactions.keys().size(): + var func_call: Variant = saved_interactions.keys()[index] + summary[func_call] = saved_interactions[func_call] + return summary + + +func verify_no_more_interactions() -> Dictionary: + var summary := Dictionary() + var called_functions: Array[Variant] = saved_interactions.keys() + if called_functions != verified_interactions: + # collect the not verified functions + var called_but_not_verified := called_functions.duplicate() + for index in verified_interactions.size(): + called_but_not_verified.erase(verified_interactions[index]) + + for index in called_but_not_verified.size(): + var not_verified: Variant = called_but_not_verified[index] + summary[not_verified] = saved_interactions[not_verified] + return summary + + +func reset_interactions() -> void: + saved_interactions.clear() diff --git a/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd.uid b/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd.uid new file mode 100644 index 0000000..5e82f63 --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd.uid @@ -0,0 +1 @@ +uid://cm54ueromtvck diff --git a/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd b/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd new file mode 100644 index 0000000..091241d --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd @@ -0,0 +1,8 @@ +class_name GdUnitSpyFunctionDoubler +extends GdFunctionDoubler + + +func double(func_descriptor: GdFunctionDescriptor) -> PackedStringArray: + return GdUnitFunctionDoublerBuilder.new(func_descriptor)\ + .with_verify_block()\ + .build() diff --git a/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd.uid b/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd.uid new file mode 100644 index 0000000..aa06fff --- /dev/null +++ b/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd.uid @@ -0,0 +1 @@ +uid://bl3ctw6rta33t diff --git a/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd b/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd new file mode 100644 index 0000000..124d3d4 --- /dev/null +++ b/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd @@ -0,0 +1,73 @@ +# This class defines a value extractor by given function name and args +class_name GdUnitFuncValueExtractor +extends GdUnitValueExtractor + +var _func_names :PackedStringArray +var _args :Array + +func _init(func_name :String, p_args :Array) -> void: + _func_names = func_name.split(".") + _args = p_args + + +func func_names() -> PackedStringArray: + return _func_names + + +func args() -> Array: + return _args + + +# Extracts a value by given `func_name` and `args`, +# Allows to use a chained list of functions setarated ba a dot. +# e.g. "func_a.func_b.name" +# do calls instance.func_a().func_b().name() and returns finally the name +# If a function returns an array, all elements will by collected in a array +# e.g. "get_children.get_name" checked a node +# do calls node.get_children() for all childs get_name() and returns all names in an array +# +# if the value not a Object or not accesible be `func_name` the value is converted to `"n.a."` +# expecing null values +func extract_value(value: Variant) -> Variant: + if value == null: + return null + for func_name in func_names(): + if GdArrayTools.is_array_type(value): + var values := Array() + @warning_ignore("unsafe_cast") + for element: Variant in (value as Array): + values.append(_call_func(element, func_name)) + value = values + else: + value = _call_func(value, func_name) + var type := typeof(value) + if type == TYPE_STRING_NAME: + return str(value) + if type == TYPE_STRING and value == "n.a.": + return value + return value + + +func _call_func(value :Variant, func_name :String) -> Variant: + # for array types we need to call explicit by function name, using funcref is only supported for Objects + # TODO extend to all array functions + if GdArrayTools.is_array_type(value) and func_name == "empty": + @warning_ignore("unsafe_cast") + return (value as Array).is_empty() + + if is_instance_valid(value): + # extract from function + var obj_value: Object = value + if obj_value.has_method(func_name): + var extract := Callable(obj_value, func_name) + if extract.is_valid(): + return obj_value.call(func_name) if args().is_empty() else obj_value.callv(func_name, args()) + else: + # if no function exists than try to extract form parmeters + var parameter: Variant = obj_value.get(func_name) + if parameter != null: + return parameter + # nothing found than return 'n.a.' + if GdUnitSettings.is_verbose_assert_warnings(): + push_warning("Extracting value from element '%s' by func '%s' failed! Converting to \"n.a.\"" % [value, func_name]) + return "n.a." diff --git a/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd.uid b/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd.uid new file mode 100644 index 0000000..696d51f --- /dev/null +++ b/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd.uid @@ -0,0 +1 @@ +uid://cyh160ap14bxf diff --git a/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd b/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd new file mode 100644 index 0000000..906ee2c --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd @@ -0,0 +1,24 @@ +## A fuzzer that generates random boolean values for testing.[br] +## +## This is useful for testing code paths that +## depend on boolean conditions, flags, or toggle states.[br] +## +## [b]Usage example:[/b] +## [codeblock] +## func test_toggle_feature(fuzzer := BoolFuzzer.new(), _fuzzer_iterations = 100): +## var enabled := fuzzer.next_value() +## my_feature.set_enabled(enabled) +## assert_bool(my_feature.is_enabled()),is_equal(enabled) +## [/codeblock] +class_name BoolFuzzer +extends Fuzzer + + +## Generates a random boolean value.[br] +## +## Returns either [code]true[/code] or [code]false[/code] with equal probability. +## This method is called automatically during fuzz testing iterations.[br] +## +## @returns A randomly generated boolean value ([code]true[/code] or [code]false[/code]). +func next_value() -> bool: + return randi() % 2 diff --git a/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd.uid new file mode 100644 index 0000000..2b53bc6 --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd.uid @@ -0,0 +1 @@ +uid://drgf2r2vsg4oa diff --git a/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd b/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd new file mode 100644 index 0000000..5da1a77 --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd @@ -0,0 +1,37 @@ +## A fuzzer that generates random floating-point values within a specified range.[br] +## +## This is particularly useful for testing numerical calculations, +## physics simulations, shader parameters, or any code that processes floating-point +## values.[br] +## +## [b]Usage example:[/b] +## [codeblock] +## func test_calculate_damage(fuzzer := FloatFuzzer.new(0.0, 100.0), _fuzzer_iterations := 500): +## var damage := fuzzer.next_value() +## var result = calculate_damage_reduction(damage) +## assert_float(result).is_between(0.0, damage) +## [/codeblock] +## [br] +## [b]Note:[/b] The range is inclusive on both ends, and values are uniformly distributed. +class_name FloatFuzzer +extends Fuzzer + +## Minimum value (inclusive) for generated floats. +var _from: float = 0 +## Maximum value (inclusive) for generated floats. +var _to: float = 0 + +func _init(from: float, to: float) -> void: + assert(from <= to, "Invalid range!") + _from = from + _to = to + + +## Generates a random float value within the configured range.[br] +## +## Returns a uniformly distributed random float between [member _from] and +## [member _to] (inclusive). Each call produces a new random value.[br] +## +## @returns A random float value within the specified range. +func next_value() -> float: + return randf_range(_from, _to) diff --git a/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd.uid new file mode 100644 index 0000000..eba5d2f --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd.uid @@ -0,0 +1 @@ +uid://ciqva85n3dycr diff --git a/addons/gdUnit4/src/fuzzers/Fuzzer.gd b/addons/gdUnit4/src/fuzzers/Fuzzer.gd new file mode 100644 index 0000000..e4e597d --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/Fuzzer.gd @@ -0,0 +1,80 @@ +## Base interface for fuzz testing.[br] +## +## Fuzzer is an abstract base class that provides the foundation for creating +## custom fuzzers used in automated testing. Fuzz testing (fuzzing) is a software +## testing technique that involves providing invalid, unexpected, or random data +## as inputs to a program to find bugs and potential security vulnerabilities. +## [br][br] +## To use a fuzzer in your test cases, add optional parameters to your test function: +## [codeblock] +## func test_foo(fuzzer := Fuzzers.randomInt(), _fuzzer_iterations := 10, _fuzzer_seed := 12345): +## var value := fuzzer.next_value() +## # Test logic using the fuzzed value +## [/codeblock] +## [br] +## @tutorial(Fuzzing on Wikipedia): https://en.wikipedia.org/wiki/Fuzzing +@abstract +class_name Fuzzer +extends RefCounted + +## Default number of iterations for fuzz testing when not specified. +const ITERATION_DEFAULT_COUNT := 1000 +## Parameter name for passing the fuzzer instance to test functions. +const ARGUMENT_FUZZER_INSTANCE := "fuzzer" +## Parameter name for specifying the number of iterations in test functions. +const ARGUMENT_ITERATIONS := "fuzzer_iterations" +## Parameter name for specifying the random seed in test functions. +const ARGUMENT_SEED := "fuzzer_seed" + +## Current iteration index during fuzzing execution. +var _iteration_index := 0 +## Maximum number of iterations to run for this fuzzer. +var _iteration_limit := ITERATION_DEFAULT_COUNT + + +## Generates the next fuzz value.[br] +## +## This abstract method must be implemented by derived classes to provide +## the specific fuzzing logic for generating test values.[br] +## +## [b]Example implementation:[/b] +## [codeblock] +## func next_value() -> int: +## return randi_range(0, 100) +## [/codeblock] +## +## @returns The next generated fuzz value. The type depends on the specific fuzzer implementation. +@abstract +func next_value() -> Variant + + +## Returns the current iteration index.[br] +## +## Useful for tracking progress during fuzzing or for debugging purposes +## when a specific iteration causes a failure.[br] +## +## [b]Example:[/b] +## [codeblock] +## if fuzzer.iteration_index() % 100 == 0: +## print("Processed %d iterations" % fuzzer.iteration_index()) +## [/codeblock] +## +## @returns The current iteration index, starting from 0. +func iteration_index() -> int: + return _iteration_index + + +## Returns the maximum number of iterations for this fuzzer.[br] +## +## This value determines how many times the fuzzer will generate values +## during a test run. It can be overridden by the [code]fuzzer_iterations[/code] +## parameter in test functions.[br] +## +## [b]Example:[/b] +## [codeblock] +## print("Running %d fuzzing iterations" % fuzzer.iteration_limit()) +## [/codeblock] +## +## @returns The maximum number of iterations to be executed. +func iteration_limit() -> int: + return _iteration_limit diff --git a/addons/gdUnit4/src/fuzzers/Fuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/Fuzzer.gd.uid new file mode 100644 index 0000000..ac4c00e --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/Fuzzer.gd.uid @@ -0,0 +1 @@ +uid://cepk1tdkax6ae diff --git a/addons/gdUnit4/src/fuzzers/IntFuzzer.gd b/addons/gdUnit4/src/fuzzers/IntFuzzer.gd new file mode 100644 index 0000000..4100d51 --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/IntFuzzer.gd @@ -0,0 +1,77 @@ +## A fuzzer that generates random integer values with optional even/odd constraints.[br] +## +## It supports three modes: normal (any integer), even-only, +## and odd-only generation. This is useful for testing array indices, loop counters, +## enumeration values, or any code that processes integer values.[br] +## +## [b]Usage example:[/b] +## [codeblock] +## # Test with any integer in range +## func test_array_access(fuzzer = IntFuzzer.new(0, 99), fuzzer_iterations = 100): +## var index = fuzzer.next_value() +## var array = create_array(100) +## assert(array[index] != null) +## +## # Test with only even numbers +## func test_even_processing(fuzzer := IntFuzzer.new(0, 100, IntFuzzer.EVEN)): +## var even_num := fuzzer.next_value() +## assert_int(even_num % 2).is_equal(0) +## [/codeblock] +class_name IntFuzzer +extends Fuzzer + + +## Generates any integer within the range. +enum { + NORMAL, ## Generate any integer within the specified range. + EVEN, ## Generate only even integers within the specified range. + ODD ## Generate only odd integers within the specified range. +} + + +## Minimum value (inclusive) for generated integers. +var _from: int = 0 +## Maximum value (inclusive) for generated integers. +var _to: int = 0 +## Generation mode: NORMAL, EVEN, or ODD. +var _mode: int = NORMAL + + +func _init(from: int, to: int, mode: int = NORMAL) -> void: + assert(from <= to, "Invalid range!") + _from = from + _to = to + _mode = mode + + +## Generates a random integer value based on the configured mode.[br] +## +## Returns a random integer between [member _from] and [member _to] (inclusive).[br] +## The value will be constrained according to the [member _mode]:[br] +## - [constant NORMAL]: Any integer in the range[br] +## - [constant EVEN]: Only even integers[br] +## - [constant ODD]: Only odd integers[br] +## +## [b]Example:[/b] +## [codeblock] +## var normal_fuzzer = IntFuzzer.new(1, 10, IntFuzzer.NORMAL) +## var even_fuzzer = IntFuzzer.new(1, 10, IntFuzzer.EVEN) +## var odd_fuzzer = IntFuzzer.new(1, 10, IntFuzzer.ODD) +## +## print(normal_fuzzer.next_value()) # Could be any: 1, 2, 3, ..., 10 +## print(even_fuzzer.next_value()) # Only even: 2, 4, 6, 8, 10 +## print(odd_fuzzer.next_value()) # Only odd: 1, 3, 5, 7, 9 +## [/codeblock] +## +## @returns A random integer value within the specified range and mode +func next_value() -> int: + var value := randi_range(_from, _to) + match _mode: + NORMAL: + return value + EVEN: + return int((value / 2.0) * 2) + ODD: + return int((value / 2.0) * 2 + 1) + _: + return value diff --git a/addons/gdUnit4/src/fuzzers/IntFuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/IntFuzzer.gd.uid new file mode 100644 index 0000000..3571588 --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/IntFuzzer.gd.uid @@ -0,0 +1 @@ +uid://c6mh7m1653ai1 diff --git a/addons/gdUnit4/src/fuzzers/StringFuzzer.gd b/addons/gdUnit4/src/fuzzers/StringFuzzer.gd new file mode 100644 index 0000000..277b4ac --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/StringFuzzer.gd @@ -0,0 +1,112 @@ +## A fuzzer that generates random strings with configurable length and character sets.[br] +## +## It supports custom character sets defined by patterns or ranges, +## making it ideal for testing input validation, text processing, parsers, or any +## code that handles string data.[br] +## +## The fuzzer uses a pattern syntax to define allowed characters:[br] +## - Single characters: [code]abc[/code] allows 'a', 'b', 'c'[br] +## - Ranges: [code]a-z[/code] allows lowercase letters[br] +## - Special patterns: [code]\\w[/code] (word chars), [code]\\p{L}[/code] (letters), [code]\\p{N}[/code] (numbers)[br] +## +## [b]Usage example:[/b] +## [codeblock] +## # Test with alphanumeric strings +## func test_username(fuzzer := StringFuzzer.new(3, 20, "a-zA-Z0-9"), _fuzzer_iterations := 100): +## var username _= fuzzer.next_value() +## assert_bool(validate_username(username)).is_true() +## +## # Test with special characters +## func test_password(fuzzer := StringFuzzer.new(8, 32, "a-zA-Z0-9!@#$%"), _fuzzer_iterations := 100) -> void: +## var password := fuzzer.next_value() +## assert_str(password).has_length(8, Comparator.GREATER_EQUAL).has_length(32, Comparator.LESS_EQUAL) +## [/codeblock] +class_name StringFuzzer +extends Fuzzer + +## Default character set pattern including word characters, letters, numbers, and common symbols.[br] +## Includes: word characters (\\w), Unicode letters (\\p{L}), Unicode numbers (\\p{N}), +## and the characters: +, -, _, ' +const DEFAULT_CHARSET = "\\w\\p{L}\\p{N}+-_'" + +## Minimum length for generated strings (inclusive). +var _min_length: int +## Maximum length for generated strings (inclusive). +var _max_length: int +## Array of character codes that can be used in generated strings. +var _charset: PackedInt32Array + + +func _init(min_length: int, max_length: int, pattern: String = DEFAULT_CHARSET) -> void: + _min_length = min_length + _max_length = max_length + 1 # +1 for inclusive + assert(not null or not pattern.is_empty()) + assert(_min_length > 0 and _min_length < _max_length) + _charset = _extract_charset(pattern) + + +## Generates a random string based on configured parameters.[br] +## +## Creates a string with random length between [member _min_length] and +## [member _max_length], using only characters from the configured charset. +## Each character is selected randomly and independently.[br] +## +## [b]Example:[/b] +## [codeblock] +## var fuzzer = StringFuzzer.new(5, 10, "ABC") +## for i in range(5): +## var str = fuzzer.next_value() +## print("Generated: ", str) +## # Possible outputs: "ABCAB", "BCAABCA", "CCCBAA", etc. +## assert(str.length() >= 5 and str.length() <= 10) +## for c in str: +## assert(c in ["A", "B", "C"]) +## [/codeblock] +## +## @returns A random string matching the configured constraints. +func next_value() -> String: + var value := PackedInt32Array() + var max_char := len(_charset) + var length: int = max(_min_length, randi() % _max_length) + for i in length: + @warning_ignore("return_value_discarded") + value.append(_charset[randi() % max_char]) + return value.to_byte_array().get_string_from_utf32() + + +static func _extract_charset(pattern: String) -> PackedInt32Array: + var reg := RegEx.new() + if reg.compile(pattern) != OK: + push_error("Invalid pattern to generate Strings! Use e.g '\\w\\p{L}\\p{N}+-_'") + return PackedInt32Array() + + var charset := PackedInt32Array() + var char_before := -1 + var index := 0 + while index < pattern.length(): + var char_current := pattern.unicode_at(index) + # - range token at first or last pos? + if char_current == 45 and (index == 0 or index == pattern.length()-1): + charset.append(char_current) + index += 1 + continue + index += 1 + # range starts + if char_current == 45 and char_before != -1: + var char_next := pattern.unicode_at(index) + var characters := _build_chars(char_before, char_next) + for character in characters: + charset.append(character) + char_before = -1 + index += 1 + continue + char_before = char_current + charset.append(char_current) + return charset + + +static func _build_chars(from: int, to: int) -> PackedInt32Array: + var characters := PackedInt32Array() + for character in range(from+1, to+1): + characters.append(character) + return characters diff --git a/addons/gdUnit4/src/fuzzers/StringFuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/StringFuzzer.gd.uid new file mode 100644 index 0000000..2a1d6a6 --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/StringFuzzer.gd.uid @@ -0,0 +1 @@ +uid://b1cspict6y3ly diff --git a/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd b/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd new file mode 100644 index 0000000..70ef31b --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd @@ -0,0 +1,45 @@ +## A fuzzer that generates random Vector2 values within a specified rectangular range.[br] +## +## This is particularly useful for testing 2D physics, movement +## systems, UI positioning, sprite coordinates, or any code that processes 2D vectors.[br] +## +## The fuzzer generates vectors where each component (x, y) is independently randomized +## within its respective range, creating a uniform distribution over the rectangular area.[br] +## +## [b]Usage example:[/b] +## [codeblock] +## # Test 2D movement within screen bounds +## func test_movement(fuzzer := Vector2Fuzzer.new(Vector2.ZERO, Vector2(1920, 1080)), _fuzzer_iterations := 200) -> void: +## var position := fuzzer.next_value() +## player.set_position(position) +## +## [/codeblock] +class_name Vector2Fuzzer +extends Fuzzer + + +## Minimum bounds for the generated vectors (inclusive for both x and y). +var _from: Vector2 +## Maximum bounds for the generated vectors (inclusive for both x and y). +var _to: Vector2 + + +func _init(from: Vector2, to: Vector2) -> void: + assert(from <= to, "Invalid range!") + _from = from + _to = to + + +## Generates a random Vector2 within the configured rectangular range.[br] +## +## Returns a Vector2 where each component is independently randomized:[br] +## - x: random float between [code]_from.x[/code] and [code]_to.x[/code][br] +## - y: random float between [code]_from.y[/code] and [code]_to.y[/code][br] +## +## The distribution is uniform over the rectangular area defined by the bounds.[br] +## +## @returns A random Vector2 within the specified range. +func next_value() -> Vector2: + var x := randf_range(_from.x, _to.x) + var y := randf_range(_from.y, _to.y) + return Vector2(x, y) diff --git a/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd.uid new file mode 100644 index 0000000..bd709fc --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd.uid @@ -0,0 +1 @@ +uid://cj0ehken213y1 diff --git a/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd b/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd new file mode 100644 index 0000000..b54870d --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd @@ -0,0 +1,48 @@ +## A fuzzer that generates random Vector3 values within a specified box range.[br] +## +## This is particularly useful for testing 3D physics, spatial +## positioning, camera systems, particle effects, or any code that processes 3D vectors.[br] +## +## The fuzzer generates vectors where each component (x, y, z) is independently +## randomized within its respective range, creating a uniform distribution over the +## 3D box volume.[br] +## +## [b]Usage example:[/b] +## [codeblock] +## # Test 3D object placement within world bounds +## func test_spawn_position(fuzzer := Vector3Fuzzer.new(Vector3(-100, 0, -100), Vector3(100, 50, 100)), _fuzzer_iterations := 300): +## var position := fuzzer.next_value() +## var object = spawn_object(position) +## +## [/codeblock] +class_name Vector3Fuzzer +extends Fuzzer + + +## Minimum bounds for the generated vectors (inclusive for x, y, and z). +var _from: Vector3 +## Maximum bounds for the generated vectors (inclusive for x, y, and z). +var _to: Vector3 + + +func _init(from: Vector3, to: Vector3) -> void: + assert(from <= to, "Invalid range!") + _from = from + _to = to + + +## Generates a random Vector3 within the configured box range.[br] +## +## Returns a Vector3 where each component is independently randomized:[br] +## - x: random float between [code]_from.x[/code] and [code]_to.x[/code][br] +## - y: random float between [code]_from.y[/code] and [code]_to.y[/code][br] +## - z: random float between [code]_from.z[/code] and [code]_to.z[/code][br] +## +## The distribution is uniform over the 3D box volume defined by the bounds.[br] +## +## @returns A random Vector3 within the specified range. +func next_value() -> Vector3: + var x := randf_range(_from.x, _to.x) + var y := randf_range(_from.y, _to.y) + var z := randf_range(_from.z, _to.z) + return Vector3(x, y, z) diff --git a/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd.uid new file mode 100644 index 0000000..50e3d6b --- /dev/null +++ b/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd.uid @@ -0,0 +1 @@ +uid://dnkol1f4r1plf diff --git a/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd b/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd new file mode 100644 index 0000000..bd50313 --- /dev/null +++ b/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd @@ -0,0 +1,11 @@ +class_name AnyArgumentMatcher +extends GdUnitArgumentMatcher + + +@warning_ignore("unused_parameter") +func is_match(value :Variant) -> bool: + return true + + +func _to_string() -> String: + return "any()" diff --git a/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd.uid new file mode 100644 index 0000000..5fb9f0b --- /dev/null +++ b/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd.uid @@ -0,0 +1 @@ +uid://do0y8hcxa0cl1 diff --git a/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd b/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd new file mode 100644 index 0000000..ba34431 --- /dev/null +++ b/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd @@ -0,0 +1,50 @@ +class_name AnyBuildInTypeArgumentMatcher +extends GdUnitArgumentMatcher + +var _type : PackedInt32Array = [] + + +func _init(type :PackedInt32Array) -> void: + _type = type + + +func is_match(value :Variant) -> bool: + return _type.has(typeof(value)) + + +func _to_string() -> String: + match _type[0]: + TYPE_BOOL: return "any_bool()" + TYPE_STRING, TYPE_STRING_NAME: return "any_string()" + TYPE_INT: return "any_int()" + TYPE_FLOAT: return "any_float()" + TYPE_COLOR: return "any_color()" + TYPE_VECTOR2: return "any_vector2()" if _type.size() == 1 else "any_vector()" + TYPE_VECTOR2I: return "any_vector2i()" + TYPE_VECTOR3: return "any_vector3()" + TYPE_VECTOR3I: return "any_vector3i()" + TYPE_VECTOR4: return "any_vector4()" + TYPE_VECTOR4I: return "any_vector4i()" + TYPE_RECT2: return "any_rect2()" + TYPE_RECT2I: return "any_rect2i()" + TYPE_PLANE: return "any_plane()" + TYPE_QUATERNION: return "any_quat()" + TYPE_AABB: return "any_aabb()" + TYPE_BASIS: return "any_basis()" + TYPE_TRANSFORM2D: return "any_transform_2d()" + TYPE_TRANSFORM3D: return "any_transform_3d()" + TYPE_NODE_PATH: return "any_node_path()" + TYPE_RID: return "any_rid()" + TYPE_OBJECT: return "any_object()" + TYPE_DICTIONARY: return "any_dictionary()" + TYPE_ARRAY: return "any_array()" + TYPE_PACKED_BYTE_ARRAY: return "any_packed_byte_array()" + TYPE_PACKED_INT32_ARRAY: return "any_packed_int32_array()" + TYPE_PACKED_INT64_ARRAY: return "any_packed_int64_array()" + TYPE_PACKED_FLOAT32_ARRAY: return "any_packed_float32_array()" + TYPE_PACKED_FLOAT64_ARRAY: return "any_packed_float64_array()" + TYPE_PACKED_STRING_ARRAY: return "any_packed_string_array()" + TYPE_PACKED_VECTOR2_ARRAY: return "any_packed_vector2_array()" + TYPE_PACKED_VECTOR3_ARRAY: return "any_packed_vector3_array()" + TYPE_PACKED_COLOR_ARRAY: return "any_packed_color_array()" + _: return "any()" diff --git a/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd.uid new file mode 100644 index 0000000..7ff429a --- /dev/null +++ b/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd.uid @@ -0,0 +1 @@ +uid://brdcagjnpni7 diff --git a/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd b/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd new file mode 100644 index 0000000..b5e3de3 --- /dev/null +++ b/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd @@ -0,0 +1,32 @@ +class_name AnyClazzArgumentMatcher +extends GdUnitArgumentMatcher + +var _clazz :Object + + +func _init(clazz :Object) -> void: + _clazz = clazz + + +func is_match(value :Variant) -> bool: + if typeof(value) != TYPE_OBJECT: + return false + if is_instance_valid(value) and GdObjects.is_script(_clazz): + @warning_ignore("unsafe_cast") + return (value as Object).get_script() == _clazz + return is_instance_of(value, _clazz) + + +func _to_string() -> String: + if (_clazz as Object).is_class("GDScriptNativeClass"): + @warning_ignore("unsafe_method_access") + var instance :Object = _clazz.new() + var clazz_name := instance.get_class() + if not instance is RefCounted: + instance.free() + return "any_class(<"+clazz_name+">)"; + if _clazz is GDScript: + var result := GdObjects.extract_class_name(_clazz) + if result.is_success(): + return "any_class(<"+ result.value() + ">)" + return "any_class()" diff --git a/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd.uid new file mode 100644 index 0000000..790e6a7 --- /dev/null +++ b/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd.uid @@ -0,0 +1 @@ +uid://beaot7k0vwane diff --git a/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd b/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd new file mode 100644 index 0000000..f779bd7 --- /dev/null +++ b/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd @@ -0,0 +1,22 @@ +class_name ChainedArgumentMatcher +extends GdUnitArgumentMatcher + +var _matchers :Array + + +func _init(matchers :Array) -> void: + _matchers = matchers + + +func is_match(arguments :Variant) -> bool: + var arg_array: Array = arguments + if arg_array == null or arg_array.size() != _matchers.size(): + return false + + for index in arg_array.size(): + var arg: Variant = arg_array[index] + var matcher: GdUnitArgumentMatcher = _matchers[index] + + if not matcher.is_match(arg): + return false + return true diff --git a/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd.uid new file mode 100644 index 0000000..f6d841f --- /dev/null +++ b/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd.uid @@ -0,0 +1 @@ +uid://cj3b18iqi6ab2 diff --git a/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd b/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd new file mode 100644 index 0000000..2d387ed --- /dev/null +++ b/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd @@ -0,0 +1,22 @@ +class_name EqualsArgumentMatcher +extends GdUnitArgumentMatcher + +var _current :Variant +var _auto_deep_check_mode :bool + + +func _init(current :Variant, auto_deep_check_mode := false) -> void: + _current = current + _auto_deep_check_mode = auto_deep_check_mode + + +func is_match(value :Variant) -> bool: + var case_sensitive_check := true + return GdObjects.equals(_current, value, case_sensitive_check, compare_mode(value)) + + +func compare_mode(value :Variant) -> GdObjects.COMPARE_MODE: + if _auto_deep_check_mode and is_instance_valid(value): + # we do deep check on all InputEvent's + return GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST if value is InputEvent else GdObjects.COMPARE_MODE.OBJECT_REFERENCE + return GdObjects.COMPARE_MODE.OBJECT_REFERENCE diff --git a/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd.uid new file mode 100644 index 0000000..fb4420a --- /dev/null +++ b/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd.uid @@ -0,0 +1 @@ +uid://wmkjc3oq2jvv diff --git a/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd b/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd new file mode 100644 index 0000000..d5adc4b --- /dev/null +++ b/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd @@ -0,0 +1,13 @@ +## The base class of all argument matchers +class_name GdUnitArgumentMatcher +extends RefCounted + + +@warning_ignore("unused_parameter") +func is_match(value: Variant) -> bool: + return true + + +func _to_string() -> String: + assert(false, "`_to_string()` Is not implemented!") + return "" diff --git a/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd.uid new file mode 100644 index 0000000..a59f453 --- /dev/null +++ b/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd.uid @@ -0,0 +1 @@ +uid://c8rpmkb7wktr6 diff --git a/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd b/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd new file mode 100644 index 0000000..6a70cc1 --- /dev/null +++ b/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd @@ -0,0 +1,42 @@ +class_name GdUnitArgumentMatchers +extends RefCounted + +const TYPE_ANY = TYPE_MAX + 100 + + +static func to_matcher(arguments: Array[Variant], auto_deep_check_mode := false) -> ChainedArgumentMatcher: + var matchers: Array[Variant] = [] + for arg: Variant in arguments: + # argument is already a matcher + if arg is GdUnitArgumentMatcher: + matchers.append(arg) + else: + # pass argument into equals matcher + matchers.append(EqualsArgumentMatcher.new(arg, auto_deep_check_mode)) + return ChainedArgumentMatcher.new(matchers) + + +static func any() -> GdUnitArgumentMatcher: + return AnyArgumentMatcher.new() + + +static func by_type(type: int) -> GdUnitArgumentMatcher: + return AnyBuildInTypeArgumentMatcher.new([type]) + + +static func by_types(types: PackedInt32Array) -> GdUnitArgumentMatcher: + return AnyBuildInTypeArgumentMatcher.new(types) + + +static func any_class(clazz: Object) -> GdUnitArgumentMatcher: + return AnyClazzArgumentMatcher.new(clazz) + + +static func is_variant_string_matching(value: Variant) -> GdUnitResult: + if value is String or value is StringName: + return GdUnitResult.success() + if value is GdUnitArgumentMatcher: + if str(value) == "any()" or str(value) == "any_string()": + return GdUnitResult.success() + return GdUnitResult.error("Only 'any()' and 'any_string()' argument matchers are allowed!") + return GdUnitResult.error("Only String or StringName types are allowed!") diff --git a/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd.uid b/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd.uid new file mode 100644 index 0000000..7bbca6c --- /dev/null +++ b/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd.uid @@ -0,0 +1 @@ +uid://cn8u3bevny2oe diff --git a/addons/gdUnit4/src/mocking/GdUnitMock.gd b/addons/gdUnit4/src/mocking/GdUnitMock.gd new file mode 100644 index 0000000..c520d92 --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMock.gd @@ -0,0 +1,45 @@ +class_name GdUnitMock +extends RefCounted + +## do call the real implementation +const CALL_REAL_FUNC = "CALL_REAL_FUNC" +## do return a default value for primitive types or null +const RETURN_DEFAULTS = "RETURN_DEFAULTS" +## do return a default value for primitive types and a fully mocked value for Object types +## builds full deep mocked object +const RETURN_DEEP_STUB = "RETURN_DEEP_STUB" + +var _value: Variant + + +func _init(value: Variant) -> void: + _value = value + + +## Selects the mock to work on, used in combination with [method GdUnitTestSuite.do_return][br] +## Example: +## [codeblock] +## do_return(false).on(myMock).is_selected() +## [/codeblock] +func on(obj: Variant) -> Variant: + if not GdUnitMock._is_mock_or_spy(obj, "__do_return"): + return obj + @warning_ignore("unsafe_method_access") + return obj.__do_return(_value) + + +## [color=yellow]`checked` is obsolete, use `on` instead [/color] +func checked(obj :Object) -> Object: + push_warning("Using a deprecated function 'checked' use `on` instead") + return on(obj) + + +static func _is_mock_or_spy(obj: Variant, func_sig: String) -> bool: + if obj is Object and not as_object(obj).has_method(func_sig): + push_error("Error: You try to use a non mock or spy!") + return false + return true + + +static func as_object(value: Variant) -> Object: + return value diff --git a/addons/gdUnit4/src/mocking/GdUnitMock.gd.uid b/addons/gdUnit4/src/mocking/GdUnitMock.gd.uid new file mode 100644 index 0000000..a00bffa --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMock.gd.uid @@ -0,0 +1 @@ +uid://bjh2us5574ov0 diff --git a/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd new file mode 100644 index 0000000..537e923 --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd @@ -0,0 +1,186 @@ +class_name GdUnitMockBuilder +extends GdUnitClassDoubler + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const MOCK_TEMPLATE :GDScript = preload("res://addons/gdUnit4/src/mocking/GdUnitMockImpl.gd") + + +static func is_push_errors() -> bool: + return GdUnitSettings.is_report_push_errors() + + +static func build(clazz :Variant, mock_mode :String, debug_write := false) -> Variant: + var push_errors := is_push_errors() + if not is_mockable(clazz, push_errors): + return null + # mocking a scene? + if GdObjects.is_scene(clazz): + var packed_scene: PackedScene = clazz + return mock_on_scene(packed_scene, debug_write) + elif typeof(clazz) == TYPE_STRING and str(clazz).ends_with(".tscn"): + var packed_scene: PackedScene = load(str(clazz)) + return mock_on_scene(packed_scene, debug_write) + # mocking a script + var instance := create_instance(clazz) + if instance == null: + push_error("Can't create instance of class %s" % clazz) + var mock := mock_on_script(instance, clazz, [ "get_script"], debug_write) + if not instance is RefCounted: + instance.free() + if mock == null: + return null + var mock_instance: Object = mock.new() + @warning_ignore("unsafe_method_access") + mock_instance.__init(mock, mock_mode) + return register_auto_free(mock_instance) + + +static func create_instance(clazz: Variant) -> Object: + match typeof(clazz): + TYPE_OBJECT: + var obj: Object = clazz + if clazz is GDScript: + var script: GDScript = clazz + var args := GdObjects.build_function_default_arguments(script, "_init") + return script.callv("new", args) + elif obj.is_class("GDScriptNativeClass"): + @warning_ignore("unsafe_method_access") + return obj.new() + TYPE_STRING: + var clazz_name: String = clazz + if clazz_name.ends_with(".gd"): + var script: GDScript = load(clazz_name) + var args := GdObjects.build_function_default_arguments(script, "_init") + return script.callv("new", args) + elif ClassDB.can_instantiate(clazz_name): + return ClassDB.instantiate(clazz_name) + + push_error("Can't create a mock validation instance from class: `%s`" % clazz) + return null + + +static func mock_on_scene(scene: PackedScene, debug_write: bool) -> Variant: + var push_errors := is_push_errors() + if not scene.can_instantiate(): + if push_errors: + push_error("Can't instanciate scene '%s'" % scene.resource_path) + return null + var scene_instance := scene.instantiate() + # we can only mock checked a scene with attached script + var scene_script: Script = scene_instance.get_script() + if scene_script == null: + if push_errors: + push_error("Can't create a mockable instance for a scene without script '%s'" % scene.resource_path) + @warning_ignore("return_value_discarded") + GdUnitTools.free_instance(scene_instance) + return null + + var script_path := scene_script.get_path() + var mock := mock_on_script(scene_instance, script_path, GdUnitClassDoubler.EXLCUDE_SCENE_FUNCTIONS, debug_write) + if mock == null: + return null + scene_instance.set_script(mock) + @warning_ignore("unsafe_method_access") + scene_instance.__init(mock, GdUnitMock.CALL_REAL_FUNC) + return register_auto_free(scene_instance) + + +static func get_class_info(clazz :Variant) -> Dictionary: + var clazz_name :String = GdObjects.extract_class_name(clazz).value() + var clazz_path := GdObjects.extract_class_path(clazz) + return { + "class_name" : clazz_name, + "class_path" : clazz_path + } + + +static func mock_on_script(instance :Object, clazz :Variant, function_excludes :PackedStringArray, debug_write :bool) -> GDScript: + var function_doubler := GdUnitMockFunctionDoubler.new() + var class_info := get_class_info(clazz) + var clazz_name :String = class_info.get("class_name") + var clazz_path :PackedStringArray = class_info.get("class_path", [clazz_name]) + var mock_template := MOCK_TEMPLATE.source_code.format({ + "instance_id" : abs(instance.get_instance_id()), + "gdunit_source_class": clazz_name if clazz_path.is_empty() else clazz_path[0] + }) + var lines := load_template(mock_template, class_info) + lines += double_functions(instance, clazz_name, clazz_path, function_doubler, function_excludes) + # We disable warning/errors for inferred_declaration + if Engine.get_version_info().hex >= 0x40400: + lines.insert(0, '@warning_ignore_start("inferred_declaration")') + lines.append('@warning_ignore_restore("inferred_declaration")') + + var mock := GDScript.new() + mock.source_code = "\n".join(lines) + mock.resource_name = "Mock%s_%d.gd" % [clazz_name, Time.get_ticks_msec()] + mock.resource_path = "%s/%s" % [GdUnitFileAccess.create_temp_dir("mock"), mock.resource_name] + + if debug_write: + @warning_ignore("return_value_discarded") + DirAccess.remove_absolute(mock.resource_path) + @warning_ignore("return_value_discarded") + ResourceSaver.save(mock, mock.resource_path) + var error := mock.reload(true) + if error != OK: + push_error("Critical!!!, MockBuilder error, please contact the developer.") + return null + return mock + + +static func is_mockable(clazz :Variant, push_errors :bool=false) -> bool: + var clazz_type := typeof(clazz) + if clazz_type != TYPE_OBJECT and clazz_type != TYPE_STRING: + push_error("Invalid clazz type is used") + return false + # is PackedScene + if GdObjects.is_scene(clazz): + return true + if GdObjects.is_native_class(clazz): + return true + # verify class type + if GdObjects.is_object(clazz): + if GdObjects.is_instance(clazz): + if push_errors: + push_error("It is not allowed to mock an instance '%s', use class name instead, Read 'Mocker' documentation for details" % clazz) + return false + + if not GdObjects.can_be_instantiate(clazz): + if push_errors: + push_error("Can't create a mockable instance for class '%s'" % clazz) + return false + return true + # verify by class name checked registered classes + var clazz_name: String = clazz + if ClassDB.class_exists(clazz_name): + if Engine.has_singleton(clazz_name): + if push_errors: + push_error("Mocking a singelton class '%s' is not allowed! Read 'Mocker' documentation for details" % clazz_name) + return false + if not ClassDB.can_instantiate(clazz_name): + if push_errors: + push_error("Mocking class '%s' is not allowed it cannot be instantiated!" % clazz_name) + return false + # exclude classes where name starts with a underscore + if clazz_name.find("_") == 0: + if push_errors: + push_error("Can't create a mockable instance for protected class '%s'" % clazz_name) + return false + return true + # at least try to load as a script + var clazz_path := clazz_name + if not FileAccess.file_exists(clazz_path): + if push_errors: + push_error("'%s' cannot be mocked for the specified resource path, the resource does not exist" % clazz_name) + return false + # finally verify is a script resource + var resource := load(clazz_path) + if resource == null: + if push_errors: + push_error("'%s' cannot be mocked the script cannot be loaded." % clazz_name) + return false + # finally check is extending from script + return GdObjects.is_script(resource) or GdObjects.is_scene(resource) + + +static func register_auto_free(obj :Variant) -> Variant: + return GdUnitThreadManager.get_current_context().get_execution_context().register_auto_free(obj) diff --git a/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid new file mode 100644 index 0000000..fe27e8c --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid @@ -0,0 +1 @@ +uid://bj64vsuixu17g diff --git a/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd new file mode 100644 index 0000000..f784f90 --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd @@ -0,0 +1,151 @@ +class_name DoubledMockClassSourceClassName + +################################################################################ +# internal mocking stuff +################################################################################ + +const __INSTANCE_ID := "gdunit_doubler_instance_id_{instance_id}" + + +class GdUnitMockDoublerState: + const __SOURCE_CLASS := "{gdunit_source_class}" + + var excluded_methods := PackedStringArray() + var working_mode := GdUnitMock.RETURN_DEFAULTS + var is_prepare_return := false + var return_values := Dictionary() + var return_value: Variant = null + + + func _init(working_mode_ := GdUnitMock.RETURN_DEFAULTS) -> void: + working_mode = working_mode_ + + +var __mock_state := GdUnitMockDoublerState.new() +@warning_ignore("unused_private_class_variable") +var __verifier_instance := GdUnitObjectInteractionsVerifier.new() + + +func __init(__script: GDScript, mock_working_mode: String) -> void: + super.set_script(__script) + __init_doubler() + __mock_state.working_mode = mock_working_mode + + +static func __doubler_state() -> GdUnitMockDoublerState: + if Engine.has_meta(__INSTANCE_ID): + return Engine.get_meta(__INSTANCE_ID).__mock_state + return null + + +func __init_doubler() -> void: + Engine.set_meta(__INSTANCE_ID, self) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE and Engine.has_meta(__INSTANCE_ID): + Engine.remove_meta(__INSTANCE_ID) + + +static func __get_verifier() -> GdUnitObjectInteractionsVerifier: + # not yet initialized, happens when a base class field initializer calls an overridden + # function before the mock's `__init()` had a chance to run `__init_doubler()` + if not Engine.has_meta(__INSTANCE_ID): + return null + return Engine.get_meta(__INSTANCE_ID).__verifier_instance + + +static func __is_prepare_return_value() -> bool: + var doubler_state := __doubler_state() + return doubler_state != null and doubler_state.is_prepare_return + + +static func __sort_by_argument_matcher(__left_args: Array, __right_args: Array) -> bool: + for __index in __left_args.size(): + var __larg: Variant = __left_args[__index] + if __larg is GdUnitArgumentMatcher: + return false + return true + + +# we need to sort by matcher arguments so that they are all at the end of the list +static func __sort_dictionary(__unsorted_args: Dictionary) -> Dictionary: + # only need to sort if contains more than one entry + if __unsorted_args.size() <= 1: + return __unsorted_args + var __sorted_args: Array = __unsorted_args.keys() + __sorted_args.sort_custom(__sort_by_argument_matcher) + var __sorted_result := {} + for __index in __sorted_args.size(): + var key :Variant = __sorted_args[__index] + __sorted_result[key] = __unsorted_args[key] + return __sorted_result + + +static func __save_function_return_value(__func_name: String, __func_args: Array) -> void: + var doubler_state := __doubler_state() + var mocked_return_value_by_args: Dictionary = doubler_state.return_values.get(__func_name, {}) + + mocked_return_value_by_args[__func_args] = doubler_state.return_value + doubler_state.return_values[__func_name] = __sort_dictionary(mocked_return_value_by_args) + doubler_state.return_value = null + doubler_state.is_prepare_return = false + + +static func __is_mocked_args_match(__func_args: Array, __mocked_args: Array) -> bool: + var __is_matching := false + for __index in __mocked_args.size(): + var __fuction_args: Array = __mocked_args[__index] + if __func_args.size() != __fuction_args.size(): + continue + __is_matching = true + for __arg_index in __func_args.size(): + var __func_arg: Variant = __func_args[__arg_index] + var __mock_arg: Variant = __fuction_args[__arg_index] + if __mock_arg is GdUnitArgumentMatcher: + @warning_ignore("unsafe_method_access") + __is_matching = __is_matching and __mock_arg.is_match(__func_arg) + else: + __is_matching = __is_matching and typeof(__func_arg) == typeof(__mock_arg) and __func_arg == __mock_arg + if not __is_matching: + break + if __is_matching: + break + return __is_matching + + +static func __return_mock_value(__func_name: String, __func_args: Array, __default_return_value: Variant) -> Variant: + var doubler_state := __doubler_state() + if not doubler_state.return_values.has(__func_name): + return __default_return_value + @warning_ignore("unsafe_method_access") + var __mocked_args: Array = doubler_state.return_values.get(__func_name).keys() + for __index in __mocked_args.size(): + var __margs: Variant = __mocked_args[__index] + if __is_mocked_args_match(__func_args, [__margs]): + return doubler_state.return_values[__func_name][__margs] + return __default_return_value + + +static func __is_do_not_call_real_func(__func_name: String, __func_args := []) -> bool: + var doubler_state := __doubler_state() + # not yet initialized, fall back to calling the real function + if doubler_state == null: + return false + var __is_call_real_func: bool = doubler_state.working_mode == GdUnitMock.CALL_REAL_FUNC and not doubler_state.excluded_methods.has(__func_name) + # do not call real funcions for mocked functions + if __is_call_real_func and doubler_state.return_values.has(__func_name): + @warning_ignore("unsafe_method_access") + var __mocked_args: Array = doubler_state.return_values.get(__func_name).keys() + return __is_mocked_args_match(__func_args, __mocked_args) + return !__is_call_real_func + + +func __exclude_method_call(exluded_methods: PackedStringArray) -> void: + __doubler_state().excluded_methods.append_array(exluded_methods) + + +func __do_return(mock_do_return_value: Variant) -> Object: + __doubler_state().return_value = mock_do_return_value + __doubler_state().is_prepare_return = true + return self diff --git a/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid new file mode 100644 index 0000000..cde7d27 --- /dev/null +++ b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid @@ -0,0 +1 @@ +uid://d0oxww12k7a6 diff --git a/addons/gdUnit4/src/monitor/ErrorLogEntry.gd b/addons/gdUnit4/src/monitor/ErrorLogEntry.gd new file mode 100644 index 0000000..2fc39be --- /dev/null +++ b/addons/gdUnit4/src/monitor/ErrorLogEntry.gd @@ -0,0 +1,41 @@ +extends RefCounted +class_name ErrorLogEntry + + +enum TYPE { + SCRIPT_ERROR, + PUSH_ERROR, + PUSH_WARNING +} + + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + + +var _type: TYPE +var _line: int +var _message: String +var _stack_trace: GdUnitStackTrace + + +func _init(type: TYPE, line: int, message: String, stack_trace: GdUnitStackTrace) -> void: + _type = type + _line = line + _message = message + _stack_trace = stack_trace + + +func _to_string() -> String: + return _message + + +static func of_push_warning(line: int, message: String, stack_trace: GdUnitStackTrace) -> ErrorLogEntry: + return ErrorLogEntry.new(TYPE.PUSH_WARNING, line, message, stack_trace) + + +static func of_push_error(line: int, message: String, stack_trace: GdUnitStackTrace) -> ErrorLogEntry: + return ErrorLogEntry.new(TYPE.PUSH_ERROR, line, message, stack_trace) + + +static func of_script_error(line: int, message: String, stack_trace: GdUnitStackTrace) -> ErrorLogEntry: + return ErrorLogEntry.new(TYPE.SCRIPT_ERROR, line, message, stack_trace) diff --git a/addons/gdUnit4/src/monitor/ErrorLogEntry.gd.uid b/addons/gdUnit4/src/monitor/ErrorLogEntry.gd.uid new file mode 100644 index 0000000..88acea8 --- /dev/null +++ b/addons/gdUnit4/src/monitor/ErrorLogEntry.gd.uid @@ -0,0 +1 @@ +uid://uhn1hlopwbko diff --git a/addons/gdUnit4/src/monitor/GdUnitMonitor.gd b/addons/gdUnit4/src/monitor/GdUnitMonitor.gd new file mode 100644 index 0000000..b6429ca --- /dev/null +++ b/addons/gdUnit4/src/monitor/GdUnitMonitor.gd @@ -0,0 +1,24 @@ +# GdUnit Monitoring Base Class +class_name GdUnitMonitor +extends RefCounted + +var _id :String + +# constructs new Monitor with given id +func _init(p_id :String) -> void: + _id = p_id + + +# Returns the id of the monitor to uniqe identify +func id() -> String: + return _id + + +# starts monitoring +func start() -> void: + pass + + +# stops monitoring +func stop() -> void: + pass diff --git a/addons/gdUnit4/src/monitor/GdUnitMonitor.gd.uid b/addons/gdUnit4/src/monitor/GdUnitMonitor.gd.uid new file mode 100644 index 0000000..9f2fcee --- /dev/null +++ b/addons/gdUnit4/src/monitor/GdUnitMonitor.gd.uid @@ -0,0 +1 @@ +uid://dpkbuqctenj0b diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd b/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd new file mode 100644 index 0000000..f8207db --- /dev/null +++ b/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd @@ -0,0 +1,13 @@ +class_name GdUnitOrphanNodeInfo +extends RefCounted + + +var _id: int +var _type: String +var _stack_element: GdUnitStackTraceElement + + +func _init(id: int, type: String, stack_element: GdUnitStackTraceElement) -> void: + _id = id + _type = type + _stack_element = stack_element diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd.uid b/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd.uid new file mode 100644 index 0000000..270b376 --- /dev/null +++ b/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd.uid @@ -0,0 +1 @@ +uid://lk3vup8rxrfh diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd new file mode 100644 index 0000000..8ac4fed --- /dev/null +++ b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd @@ -0,0 +1,237 @@ +class_name GdUnitOrphanNodesMonitor +extends GdUnitMonitor + + +var _child_monitors: Array[GdUnitOrphanNodesMonitor] = [] +var _orphan_detection_enabled :bool +var _initial_orphans: Array[int] = [] +var _orphan_ids_at_start: Array[int] = [] +var _orphan_ids_at_stop: Array[int] = [] +var _collected_orphan_infos: Array[GdUnitOrphanNodeInfo] = [] + + +func _init(name: String) -> void: + super("OrphanNodesMonitor:" + name) + _orphan_detection_enabled = GdUnitSettings.is_verbose_orphans() + _initial_orphans = _get_orphan_node_ids() + + +func add_child_monitor(monitor: GdUnitOrphanNodesMonitor) -> void: + if not _orphan_detection_enabled: + return + _child_monitors.append(monitor) + + +func start() -> void: + if not _orphan_detection_enabled: + return + _collected_orphan_infos.clear() + # Collect current orphan id's to be filtered out at `stop` + _orphan_ids_at_start = _get_orphan_node_ids() + + +func stop() -> void: + if not _orphan_detection_enabled: + return + # Collect only new detected orphan id's, we want only to collect orphans between start and stop time + _orphan_ids_at_stop = _get_orphan_node_ids().filter(func(element: int) -> bool: + # Excluding sub monitores orphans + if _collect_child_orphan_ids().has(element): + return false + # Excluding orphans at start + return not _orphan_ids_at_start.has(element) and not _initial_orphans.has(element) + ) + + +func _collect_child_orphan_ids() -> Array[int]: + var collected_ids: Array[int] = [] + for child_monitor in _child_monitors: + collected_ids.append_array(child_monitor._orphan_ids_at_stop) + collected_ids.append_array(child_monitor._collect_child_orphan_ids()) + return collected_ids + + +func detected_orphans() -> Array[GdUnitOrphanNodeInfo]: + if not _orphan_detection_enabled: + return [] + return _collected_orphan_infos.filter(func(info: GdUnitOrphanNodeInfo) -> bool: + return info._id in _orphan_ids_at_stop + ) + + +func orphans_count() -> int: + if not _orphan_detection_enabled: + return 0 + return _orphan_ids_at_stop.size() + + +func collect() -> void: + if not _orphan_detection_enabled: + return + + stop() + if _orphan_ids_at_stop.is_empty(): + return + + var script_backtraces := Engine.capture_script_backtraces(true) + for orphan_id in _orphan_ids_at_stop: + var orphan_node := instance_from_id(orphan_id) + _collect_orphan_info(orphan_node, script_backtraces) + + +func _collect_orphan_info(orphan_node: Object, script_backtraces: Array[ScriptBacktrace]) -> void: + if orphan_node == null: + return + + var orphan_info := _find_orphan_on_backtraces(orphan_node, script_backtraces) + if orphan_info: + _collected_orphan_infos.append(orphan_info) + return + + if Engine.has_meta("GdUnitSceneRunner"): + var current_scene_runner:GdUnitSceneRunner = Engine.get_meta("GdUnitSceneRunner") + if is_instance_valid(current_scene_runner): + orphan_info = _find_orphan_at_node(orphan_node, current_scene_runner.scene()) + if orphan_info: + _collected_orphan_infos.append(orphan_info) + return + + _collected_orphan_infos.append( + GdUnitOrphanNodeInfo.new( + orphan_node.get_instance_id(), + orphan_node.get_class(), + null) + ) + + +func _find_orphan_at_node(orphan_node: Object, node: Node) -> GdUnitOrphanNodeInfo: + var script: Script = node.get_script() + if script is not GDScript: + return null + + # First search over all properties + for property in script.get_script_property_list(): + # We lookup only over user script variables + var property_usage: int = property["usage"] + if property_usage != PROPERTY_USAGE_SCRIPT_VARIABLE: + continue + + var property_type: int = property["type"] + # Is untyped or type object + if property_type in [TYPE_NIL, TYPE_OBJECT]: + var property_name: String = property["name"] + var property_instance: Variant = node.get(property_name) + @warning_ignore("unsafe_cast") + var property_as_node := property_instance as Node + if property_as_node == null: + continue + # If node match the curren property object + if property_as_node == orphan_node: + var property_class: String = property["class_name"] + var source_line := _find_line_for_property(script, "", property_name) + return GdUnitOrphanNodeInfo.new( + orphan_node.get_instance_id(), + property_class, + GdUnitStackTraceElement.new( + script.resource_path, + source_line, + property_name) + ) + + # Otherwise we need to search on child node script properties + var orphan_info := _find_orphan_at_node(orphan_node, property_as_node) + if orphan_info: + return orphan_info + + # Second over all children + for child_node in node.get_children(): + var orphan_info := _find_orphan_at_node(orphan_node, child_node) + if orphan_info: + return orphan_info + return null + + +func _find_orphan_on_backtraces(orphan_node: Object, script_backtraces: Array[ScriptBacktrace]) -> GdUnitOrphanNodeInfo: + for script_backtrace in script_backtraces: + for frame in script_backtrace.get_frame_count(): + var frame_file := script_backtrace.get_frame_file(frame) + if GdUnitStackTrace.filter_sources(frame_file): + continue + + # Scan function variables + for l_index in script_backtrace.get_local_variable_count(frame): + var variable: Variant = script_backtrace.get_local_variable_value(frame, l_index) + if typeof(variable) in [TYPE_NIL, TYPE_OBJECT]: + @warning_ignore("unsafe_cast") + var node := variable as Node + if node == null: + continue + if variable == orphan_node: + var variable_name := script_backtrace.get_local_variable_name(frame, l_index) + var source_script := script_backtrace.get_frame_file(frame) + var source_function := script_backtrace.get_frame_function(frame) + var script: Script = load(source_script) + var source_line := _find_line_for_property(script, source_function, variable_name) + return GdUnitOrphanNodeInfo.new( + orphan_node.get_instance_id(), + orphan_node.get_class(), + GdUnitStackTraceElement.new(source_script, source_line, variable_name) + ) + else: + var orphan_info := _find_orphan_at_node(orphan_node, node) + if orphan_info: + return orphan_info + + # Scan class members + for m_index in script_backtrace.get_member_variable_count(frame): + var member: Variant = script_backtrace.get_member_variable_value(frame, m_index) + if typeof(member) in [TYPE_NIL, TYPE_OBJECT]: + @warning_ignore("unsafe_cast") + var node := member as Node + if node == null: + continue + if member == orphan_node: + var member_name := script_backtrace.get_member_variable_name(frame, m_index) + return GdUnitOrphanNodeInfo.new( + orphan_node.get_instance_id(), + orphan_node.get_class(), + GdUnitStackTraceElement.new( + script_backtrace.get_frame_file(frame), + script_backtrace.get_frame_line(frame), + member_name)) + else: + var orphan_info := _find_orphan_at_node(orphan_node, node) + if orphan_info: + return orphan_info + return null + + +func _find_line_for_property(script: Script, func_name: String, property_name: String) -> int: + if script == null or not script.has_source_code(): + return -1 + var lines := script.get_source_code().split("\n") + var func_start_index := 0 + for index in range(0, lines.size()): + var line := lines[index] + if not func_name.is_empty(): + if line.begins_with("func") and line.contains(func_name): + func_start_index = index + 1 + break; + + for index in range(func_start_index, lines.size()): + var line := lines[index] + if line.contains(property_name): + return index + 1 + if line.begins_with("func"): + break + return -1 + + +static func _get_orphan_node_ids() -> Array[int]: + @warning_ignore("unsafe_property_access", "unsafe_method_access") + var ids: Array[int] = Engine.get_main_loop().root.get_orphan_node_ids() + # a node already queued for deletion is guaranteed to be freed, it is never a leak + return ids.filter(func(node_id: int) -> bool: + var node := instance_from_id(node_id) as Node + return node == null or not node.is_queued_for_deletion() + ) diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid new file mode 100644 index 0000000..66dce66 --- /dev/null +++ b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid @@ -0,0 +1 @@ +uid://dle7kfwopyykt diff --git a/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd new file mode 100644 index 0000000..24042c6 --- /dev/null +++ b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd @@ -0,0 +1,113 @@ +class_name GodotGdErrorMonitor +extends GdUnitMonitor + + +var _logger: GdUnitLogger + + +class GdUnitLogger extends Logger: + var _entries: Array[ErrorLogEntry] = [] + var _is_report_push_errors: bool + var _is_report_script_errors: bool + + + func _init(is_report_push_errors: bool, is_report_script_errors: bool) -> void: + _is_report_push_errors = is_report_push_errors + _is_report_script_errors = is_report_script_errors + OS.add_logger(self) + + + func entries() -> Array[ErrorLogEntry]: + return _entries + + func erase_log_entry(log_entry: ErrorLogEntry) -> void: + for entry in _entries: + if entry._type == log_entry._type and entry._message == log_entry._message: + _entries.erase(entry) + return + + + func _log_error( + _function: String, + _file: String, + _line: int, + message: String, + _rationale: String, + _editor_notify: bool, + error_type: int, + script_backtraces: Array[ScriptBacktrace] + ) -> void: + + var stack_trace := GdUnitStackTrace.from_script_backtraces(script_backtraces) + if stack_trace.get_frames().size() == 0: + stack_trace = GdUnitStackTrace.new([ + GdUnitStackTraceElement.new(_file, _line, _function) + ]) + + match error_type: + ErrorType.ERROR_TYPE_WARNING: + if _is_report_push_errors: + _entries.append(ErrorLogEntry.of_push_warning(stack_trace.get_line_number(), message, stack_trace)) + + ErrorType.ERROR_TYPE_ERROR: + if _is_report_push_errors: + _entries.append(ErrorLogEntry.of_push_error(stack_trace.get_line_number(), message, stack_trace)) + + ErrorType.ERROR_TYPE_SCRIPT: + if _is_report_script_errors: + _entries.append(ErrorLogEntry.of_script_error(stack_trace.get_line_number(), message, stack_trace)) + + ErrorType.ERROR_TYPE_SHADER: + pass + _: + prints("Unknwon log type", message) + + func _log_message(_message: String, _error: bool) -> void: + pass + + + + + +func _init() -> void: + super("GdUnitLoggerMonitor") + _logger = GdUnitLogger.new(GdUnitSettings.is_report_push_errors(), GdUnitSettings.is_report_script_errors()) + + +func start() -> void: + clear_logs() + + +func stop() -> void: + pass + + +func log_entries() -> Array[ErrorLogEntry]: + return _logger.entries() + + +func erase_log_entry(log_entry: ErrorLogEntry) -> void: + _logger.erase_log_entry(log_entry) + + +func to_reports() -> Array[GdUnitReport]: + var reports_: Array[GdUnitReport] = [] + + reports_.assign(log_entries().map(_to_report)) + + return reports_ + + +static func _to_report(errorLog: ErrorLogEntry) -> GdUnitReport: + + var failure := """ + %s + %s""".dedent().trim_prefix("\n") % [ + GdAssertMessages._error("Godot Runtime Error !"), + GdAssertMessages._colored_value(errorLog._message)] + var error := GdUnitError.new(failure, errorLog._line, errorLog._stack_trace) + return GdUnitReport.new().from_error(GdUnitReport.ABORT, error) + + +func clear_logs() -> void: + log_entries().clear() diff --git a/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd.uid b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd.uid new file mode 100644 index 0000000..5f353af --- /dev/null +++ b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd.uid @@ -0,0 +1 @@ +uid://be25rtwmwty6w diff --git a/addons/gdUnit4/src/network/GdUnitServer.gd b/addons/gdUnit4/src/network/GdUnitServer.gd new file mode 100644 index 0000000..4ce2f48 --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitServer.gd @@ -0,0 +1,41 @@ +@tool +extends Node + +var _client_id: int + +@onready var _server: GdUnitTcpServer = $TcpServer + + +@warning_ignore("return_value_discarded") +func _ready() -> void: + var result := _server.start() + if result.is_error(): + push_error(result.error_message()) + return + var server_port :int = result.value() + Engine.set_meta("gdunit_server_port", server_port) + _server.client_connected.connect(_on_client_connected) + _server.client_disconnected.connect(_on_client_disconnected) + _server.rpc_data.connect(_receive_rpc_data) + + +func _on_client_connected(client_id: int) -> void: + _client_id = client_id + GdUnitSignals.instance().gdunit_client_connected.emit(client_id) + + +func _on_client_disconnected(client_id: int) -> void: + GdUnitSignals.instance().gdunit_client_disconnected.emit(client_id) + + +func _receive_rpc_data(p_rpc: RPC) -> void: + if p_rpc is RPCMessage: + var rpc_message: RPCMessage = p_rpc + GdUnitSignals.instance().gdunit_message.emit(rpc_message.message()) + return + if p_rpc is RPCGdUnitEvent: + var rpc_event: RPCGdUnitEvent = p_rpc + var event := rpc_event.event() + GdUnitSignals.instance().gdunit_event.emit(event) + if event.type() == GdUnitEvent.SESSION_CLOSE and _server != null: + _server.disconnect_client(_client_id) diff --git a/addons/gdUnit4/src/network/GdUnitServer.gd.uid b/addons/gdUnit4/src/network/GdUnitServer.gd.uid new file mode 100644 index 0000000..d896348 --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitServer.gd.uid @@ -0,0 +1 @@ +uid://bn84n4baetpgm diff --git a/addons/gdUnit4/src/network/GdUnitServer.tscn b/addons/gdUnit4/src/network/GdUnitServer.tscn new file mode 100644 index 0000000..4dbe8c4 --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitServer.tscn @@ -0,0 +1,10 @@ +[gd_scene load_steps=3 format=3 uid="uid://cn5mp3tmi2gb1"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/network/GdUnitServer.gd" id="1"] +[ext_resource type="Script" path="res://addons/gdUnit4/src/network/GdUnitTcpServer.gd" id="2"] + +[node name="Control" type="Node"] +script = ExtResource("1") + +[node name="TcpServer" type="Node" parent="."] +script = ExtResource("2") diff --git a/addons/gdUnit4/src/network/GdUnitServerConstants.gd b/addons/gdUnit4/src/network/GdUnitServerConstants.gd new file mode 100644 index 0000000..d31eee7 --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitServerConstants.gd @@ -0,0 +1,6 @@ +class_name GdUnitServerConstants +extends RefCounted + +const DEFAULT_SERVER_START_RETRY_TIMES :int = 5 +const GD_TEST_SERVER_PORT :int = 31002 +const JSON_RESPONSE_DELIMITER :String = "<>" diff --git a/addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid b/addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid new file mode 100644 index 0000000..507fd8d --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid @@ -0,0 +1 @@ +uid://bmr3egl3bchc8 diff --git a/addons/gdUnit4/src/network/GdUnitTask.gd b/addons/gdUnit4/src/network/GdUnitTask.gd new file mode 100644 index 0000000..e0188a0 --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitTask.gd @@ -0,0 +1,25 @@ +class_name GdUnitTask +extends RefCounted + +const TASK_NAME = "task_name" +const TASK_ARGS = "task_args" + +var _task_name :String +var _fref :Callable + + +func _init(task_name :String,instance :Object,func_name :String) -> void: + _task_name = task_name + if not instance.has_method(func_name): + push_error("Can't create GdUnitTask, Invalid func name '%s' for instance '%s'" % [instance, func_name]) + _fref = Callable(instance, func_name) + + +func name() -> String: + return _task_name + + +func execute(args :Array) -> GdUnitResult: + if args.is_empty(): + return _fref.call() + return _fref.callv(args) diff --git a/addons/gdUnit4/src/network/GdUnitTask.gd.uid b/addons/gdUnit4/src/network/GdUnitTask.gd.uid new file mode 100644 index 0000000..839ac25 --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitTask.gd.uid @@ -0,0 +1 @@ +uid://cby0yol7qec6s diff --git a/addons/gdUnit4/src/network/GdUnitTcpClient.gd b/addons/gdUnit4/src/network/GdUnitTcpClient.gd new file mode 100644 index 0000000..d6c8a42 --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitTcpClient.gd @@ -0,0 +1,126 @@ +@tool +class_name GdUnitTcpClient +extends GdUnitTcpNode + +signal connection_succeeded(message: String) +signal connection_failed(message: String) + + +var _client_name: String +var _debug := false +var _host: String +var _port: int +var _client_id: int +var _connected: bool +var _stream: StreamPeerTCP + + +func _init(client_name := "GdUnit4 TCP Client", debug := false) -> void: + _client_name = client_name + _debug = debug + + +func _ready() -> void: + _connected = false + _stream = StreamPeerTCP.new() + #_stream.set_big_endian(true) + + +func stop() -> void: + console("Disconnecting from server") + if _stream != null: + rpc_send(_stream, RPCClientDisconnect.new().with_id(_client_id)) + if _stream != null: + _stream.disconnect_from_host() + _connected = false + + +func start(host: String, port: int) -> GdUnitResult: + _host = host + _port = port + if _connected: + return GdUnitResult.warn("Client already connected ... %s:%d" % [_host, _port]) + + # Connect client to server + if _stream.get_status() != StreamPeerTCP.STATUS_CONNECTED: + var err := _stream.connect_to_host(host, port) + #prints("connect_to_host", host, port, err) + if err != OK: + return GdUnitResult.error("GdUnit4: Can't establish client, error code: %s" % err) + return GdUnitResult.success("GdUnit4: Client connected checked port %d" % port) + + +func _process(_delta: float) -> void: + match _stream.get_status(): + StreamPeerTCP.STATUS_NONE: + return + + StreamPeerTCP.STATUS_CONNECTING: + set_process(false) + # wait until client is connected to server + for retry in 10: + @warning_ignore("return_value_discarded") + _stream.poll() + console("Waiting to connect ..") + if _stream.get_status() == StreamPeerTCP.STATUS_CONNECTING: + await get_tree().create_timer(0.500).timeout + if _stream.get_status() == StreamPeerTCP.STATUS_CONNECTED: + set_process(true) + return + set_process(true) + _stream.disconnect_from_host() + console("Connection failed") + connection_failed.emit("Connect to TCP Server %s:%d faild!" % [_host, _port]) + + StreamPeerTCP.STATUS_CONNECTED: + if not _connected: + var rpc_data :RPC = null + set_process(false) + while rpc_data == null: + await get_tree().create_timer(0.500).timeout + rpc_data = rpc_receive() + set_process(true) + _client_id = (rpc_data as RPCClientConnect).client_id() + console("Connected to Server: %d" % _client_id) + connection_succeeded.emit("Connect to TCP Server %s:%d success." % [_host, _port]) + _connected = true + process_rpc() + + StreamPeerTCP.STATUS_ERROR: + console("Connection failed") + _stream.disconnect_from_host() + connection_failed.emit("Connect to TCP Server %s:%d faild!" % [_host, _port]) + return + + +func is_client_connected() -> bool: + return _connected + + +func process_rpc() -> void: + if _stream.get_available_bytes() > 0: + var rpc_data := rpc_receive() + if rpc_data is RPCClientDisconnect: + console("RPCClientDisconnect") + GdUnitSignals.instance().gdunit_test_session_terminate.emit.call_deferred() + + +func send(data: RPC) -> void: + rpc_send(_stream, data) + + +func rpc_receive() -> RPC: + return receive_packages(_stream).front() + + +func console(value: Variant) -> void: + if _debug: + print(_client_name, ": ", value) + + +func _on_connection_failed(message: String) -> void: + console("Connection faild by: " + message) + + +func _on_connection_succeeded(message: String) -> void: + console("Connected: " + message) diff --git a/addons/gdUnit4/src/network/GdUnitTcpClient.gd.uid b/addons/gdUnit4/src/network/GdUnitTcpClient.gd.uid new file mode 100644 index 0000000..2258c06 --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitTcpClient.gd.uid @@ -0,0 +1 @@ +uid://2l088plipilv diff --git a/addons/gdUnit4/src/network/GdUnitTcpNode.gd b/addons/gdUnit4/src/network/GdUnitTcpNode.gd new file mode 100644 index 0000000..63ac473 --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitTcpNode.gd @@ -0,0 +1,55 @@ +class_name GdUnitTcpNode +extends Node + +## Maximum size of a single RPC payload. Godot's default of 64 KiB is too small for larger test +## events (e.g. reports with long stack traces), so it is raised here. Kept modest because +## `PacketPeerStream` allocates its buffers eagerly at this size, per connection. +const MAX_PACKET_SIZE := 2 * 1024 * 1024 + +# `PacketPeerStream` reassembles length-prefixed packets from a raw `StreamPeerTCP` byte stream, +# buffering partial reads across calls instead of assuming a full message always arrives at once. +var _packets := _new_packet_peer() + + +static func _new_packet_peer() -> PacketPeerStream: + var packet_peer := PacketPeerStream.new() + packet_peer.input_buffer_max_size = MAX_PACKET_SIZE + packet_peer.output_buffer_max_size = MAX_PACKET_SIZE + return packet_peer + + +func rpc_send(stream: StreamPeerTCP, data: RPC) -> void: + _packets.stream_peer = stream + var payload := data.serialize().to_utf16_buffer() + var status_code := _packets.put_packet(payload) + if status_code != OK: + push_error("'rpc_send:' Can't put_packet(), error: %s" % error_string(status_code)) + + +func receive_packages(stream: StreamPeerTCP, rpc_cb: Callable = noop) -> Array[RPC]: + var received_packages: Array[RPC] = [] + if stream.get_status() != StreamPeerTCP.STATUS_CONNECTED: + return received_packages + + _packets.stream_peer = stream + while _packets.get_available_packet_count() > 0: + var payload := _packets.get_packet() + var status_code := _packets.get_packet_error() + if status_code != OK: + push_error("'receive_packages:' Can't get_packet(), error: %s" % error_string(status_code)) + continue + + var json := payload.get_string_from_utf16() + if json.is_empty(): + push_warning("json is empty, can't process data") + continue + var data := RPC.deserialize(json) + if data == null: + continue + received_packages.append(data) + rpc_cb.call(data) + return received_packages + + +static func noop(_rpc_data: RPC) -> void: + pass diff --git a/addons/gdUnit4/src/network/GdUnitTcpNode.gd.uid b/addons/gdUnit4/src/network/GdUnitTcpNode.gd.uid new file mode 100644 index 0000000..b67f03a --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitTcpNode.gd.uid @@ -0,0 +1 @@ +uid://j2wer11ha8n1 diff --git a/addons/gdUnit4/src/network/GdUnitTcpServer.gd b/addons/gdUnit4/src/network/GdUnitTcpServer.gd new file mode 100644 index 0000000..82df46e --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitTcpServer.gd @@ -0,0 +1,140 @@ +@tool +class_name GdUnitTcpServer +extends Node + +signal client_connected(client_id: int) +signal client_disconnected(client_id: int) +@warning_ignore("unused_signal") +signal rpc_data(rpc_data: RPC) + +var _server: TCPServer +var _server_name: String + +class TcpConnection extends GdUnitTcpNode: + var _id: int + var _stream: StreamPeerTCP + + + func _init(tcp_server: TCPServer) -> void: + _stream = tcp_server.take_connection() + #_stream.set_big_endian(true) + _id = _stream.get_instance_id() + rpc_send(_stream, RPCClientConnect.new().with_id(_id)) + + + func _ready() -> void: + server().client_connected.emit(_id) + + + func close() -> void: + if _stream != null and _stream.get_status() == StreamPeerTCP.STATUS_CONNECTED: + _stream.disconnect_from_host() + queue_free() + + + func id() -> int: + return _id + + + func server() -> GdUnitTcpServer: + return get_parent() + + + func _process(_delta: float) -> void: + if _stream == null or _stream.get_status() != StreamPeerTCP.STATUS_CONNECTED: + return + receive_packages(_stream, func(rpc_data: RPC) -> void: + server().rpc_data.emit(rpc_data) + # is client disconnecting we close the server after a timeout of 1 second + if rpc_data is RPCClientDisconnect: + close() + ) + + + func disconnect_from_server() -> void: + if _stream == null or _stream.get_status() != StreamPeerTCP.STATUS_CONNECTED: + return + rpc_send(_stream, RPCClientDisconnect.new().with_id(_id)) + + + func console(_value: Variant) -> void: + #print_debug("TCP Server: ", value) + pass + + +func _init(server_name := "GdUnit4 TCP Server") -> void: + _server_name = server_name + GdUnitSignals.instance().gdunit_test_session_terminate.connect(func() -> void: + for connection in get_children(): + if connection is TcpConnection: + (connection as TcpConnection).disconnect_from_server() + ) + + +func _ready() -> void: + _server = TCPServer.new() + client_connected.connect(_on_client_connected) + client_disconnected.connect(_on_client_disconnected) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + stop() + + +func start(server_port := GdUnitServerConstants.GD_TEST_SERVER_PORT) -> GdUnitResult: + var err := OK + for retry in GdUnitServerConstants.DEFAULT_SERVER_START_RETRY_TIMES: + err = _server.listen(server_port, "127.0.0.1") + if err != OK: + prints("GdUnit4: Can't establish server checked port: %d, Error: %s" % [server_port, error_string(err)]) + server_port += 1 + prints("GdUnit4: Retry (%d) ..." % retry) + else: + break + if err != OK: + if err == ERR_ALREADY_IN_USE: + return GdUnitResult.error("GdUnit4: Can't establish server, the server is already in use. Error: %s, " % error_string(err)) + return GdUnitResult.error("GdUnit4: Can't establish server. Error: %s." % error_string(err)) + console("Successfully started checked port: %d" % server_port) + return GdUnitResult.success(server_port) + + +func stop() -> void: + if _server: + _server.stop() + for connection in get_children(): + if connection is TcpConnection: + @warning_ignore("unsafe_method_access") + connection.close() + remove_child(connection) + _server = null + + +func disconnect_client(client_id: int) -> void: + client_disconnected.emit(client_id) + + +func _process(_delta: float) -> void: + if _server != null and not _server.is_listening(): + return + # check if connection is ready to be used + if _server != null and _server.is_connection_available(): + add_child(TcpConnection.new(_server)) + + +func _on_client_connected(client_id: int) -> void: + console("Client connected %d" % client_id) + + +func _on_client_disconnected(client_id: int) -> void: + for connection in get_children(): + @warning_ignore("unsafe_method_access") + if connection is TcpConnection and connection.id() == client_id: + @warning_ignore("unsafe_method_access") + connection.close() + remove_child(connection) + + +func console(value: Variant) -> void: + print(_server_name, ": ", value) diff --git a/addons/gdUnit4/src/network/GdUnitTcpServer.gd.uid b/addons/gdUnit4/src/network/GdUnitTcpServer.gd.uid new file mode 100644 index 0000000..8df7d61 --- /dev/null +++ b/addons/gdUnit4/src/network/GdUnitTcpServer.gd.uid @@ -0,0 +1 @@ +uid://b4ku7in3xqusa diff --git a/addons/gdUnit4/src/network/rpc/RPC.gd b/addons/gdUnit4/src/network/rpc/RPC.gd new file mode 100644 index 0000000..6569cb1 --- /dev/null +++ b/addons/gdUnit4/src/network/rpc/RPC.gd @@ -0,0 +1,37 @@ +class_name RPC +extends RefCounted + + +var _data: Dictionary = {} + + +func _init(obj: Object = null) -> void: + if obj != null: + if obj.has_method("serialize"): + _data = obj.call("serialize") + else: + _data = inst_to_dict(obj) + + +func get_data() -> Object: + return dict_to_inst(_data) + + +func serialize() -> String: + return JSON.stringify(inst_to_dict(self)) + + +# using untyped version see comments below +static func deserialize(json_value: String) -> Object: + var json := JSON.new() + var err := json.parse(json_value) + if err != OK: + push_error("Can't deserialize JSON, error at line %d:\n error: %s \n json: '%s'" + % [json.get_error_line(), json.get_error_message(), json_value]) + return null + var result: Dictionary = json.get_data() + if not typeof(result) == TYPE_DICTIONARY: + push_error("Can't deserialize JSON. Expecting dictionary, error at line %d:\n error: %s \n json: '%s'" + % [result.error_line, result.error_string, json_value]) + return null + return dict_to_inst(result) diff --git a/addons/gdUnit4/src/network/rpc/RPC.gd.uid b/addons/gdUnit4/src/network/rpc/RPC.gd.uid new file mode 100644 index 0000000..6200c32 --- /dev/null +++ b/addons/gdUnit4/src/network/rpc/RPC.gd.uid @@ -0,0 +1 @@ +uid://dx3c8di3mdt5x diff --git a/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd b/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd new file mode 100644 index 0000000..6b494cf --- /dev/null +++ b/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd @@ -0,0 +1,13 @@ +class_name RPCClientConnect +extends RPC + +var _client_id: int + + +func with_id(id: int) -> RPCClientConnect: + _client_id = id + return self + + +func client_id() -> int: + return _client_id diff --git a/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd.uid b/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd.uid new file mode 100644 index 0000000..1d1eab5 --- /dev/null +++ b/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd.uid @@ -0,0 +1 @@ +uid://dxtb2nie2ie4g diff --git a/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd b/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd new file mode 100644 index 0000000..7445b9d --- /dev/null +++ b/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd @@ -0,0 +1,13 @@ +class_name RPCClientDisconnect +extends RPC + +var _client_id: int + + +func with_id(id: int) -> RPCClientDisconnect: + _client_id = id + return self + + +func client_id() -> int: + return _client_id diff --git a/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd.uid b/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd.uid new file mode 100644 index 0000000..2944fce --- /dev/null +++ b/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd.uid @@ -0,0 +1 @@ +uid://dtxvxlk01es5h diff --git a/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd b/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd new file mode 100644 index 0000000..dbf55c6 --- /dev/null +++ b/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd @@ -0,0 +1,14 @@ +class_name RPCGdUnitEvent +extends RPC + + +static func of(p_event: GdUnitEvent) -> RPCGdUnitEvent: + return RPCGdUnitEvent.new(p_event) + + +func event() -> GdUnitEvent: + return GdUnitEvent.new().deserialize(_data) + + +func _to_string() -> String: + return "RPCGdUnitEvent: " + str(_data) diff --git a/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd.uid b/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd.uid new file mode 100644 index 0000000..9d4e6b9 --- /dev/null +++ b/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd.uid @@ -0,0 +1 @@ +uid://cb1t828nnj17j diff --git a/addons/gdUnit4/src/network/rpc/RPCMessage.gd b/addons/gdUnit4/src/network/rpc/RPCMessage.gd new file mode 100644 index 0000000..1db0470 --- /dev/null +++ b/addons/gdUnit4/src/network/rpc/RPCMessage.gd @@ -0,0 +1,18 @@ +class_name RPCMessage +extends RPC + +var _message: String + + +static func of(msg :String) -> RPCMessage: + var rpc := RPCMessage.new() + rpc._message = msg + return rpc + + +func message() -> String: + return _message + + +func _to_string() -> String: + return "RPCMessage: " + _message diff --git a/addons/gdUnit4/src/network/rpc/RPCMessage.gd.uid b/addons/gdUnit4/src/network/rpc/RPCMessage.gd.uid new file mode 100644 index 0000000..52bb56b --- /dev/null +++ b/addons/gdUnit4/src/network/rpc/RPCMessage.gd.uid @@ -0,0 +1 @@ +uid://jdv7rkafqnc3 diff --git a/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd new file mode 100644 index 0000000..b55b964 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd @@ -0,0 +1,202 @@ +class_name GdUnitReportSummary +extends RefCounted + +var _resource_path: String +var _name: String +var _test_count := 0 +var _failure_count := 0 +var _error_count := 0 +var _orphan_count := 0 +var _skipped_count := 0 +var _flaky_count := 0 +var _duration := 0 +var _reports: Array[GdUnitReportSummary] = [] +var _text_formatter: Callable + + +func _init(text_formatter: Callable) -> void: + _text_formatter = text_formatter + + +func name() -> String: + return _name + + +func path() -> String: + return _resource_path.get_base_dir().replace("res://", "") + + +func get_resource_path() -> String: + return _resource_path + + +func suite_count() -> int: + return _reports.size() + + +func suite_executed_count() -> int: + var executed := _reports.size() + for report in _reports: + if report.test_count() == report.skipped_count(): + executed -= 1 + return executed + + +func test_count() -> int: + var count := _test_count + for report in _reports: + count += report.test_count() + return count + + +func test_executed_count() -> int: + return test_count() - skipped_count() + + +func success_count() -> int: + return test_count() - error_count() - failure_count() - flaky_count() - skipped_count() + + +func error_count() -> int: + return _error_count + + +func failure_count() -> int: + return _failure_count + + +func skipped_count() -> int: + return _skipped_count + + +func flaky_count() -> int: + return _flaky_count + + +func orphan_count() -> int: + return _orphan_count + + +func duration() -> int: + return _duration + + +func get_reports() -> Array: + return _reports + + +func add_report(report: GdUnitReportSummary) -> void: + _reports.append(report) + + +func report_state() -> String: + return calculate_state(error_count(), failure_count(), orphan_count(), flaky_count(), skipped_count()) + + +func succes_rate() -> String: + return calculate_succes_rate(test_count(), error_count(), failure_count()) + + +@warning_ignore("shadowed_variable") +func add_testcase(resource_path: String, suite_name: String, test_name: String) -> void: + for report: GdUnitTestSuiteReport in _reports: + if report.get_resource_path() == resource_path: + var test_report := GdUnitTestCaseReport.new(resource_path, suite_name, test_name, _text_formatter) + report.add_or_create_test_report(test_report) + + +func add_reports( + p_resource_path: String, + p_test_name: String, + p_reports: Array[GdUnitReport]) -> void: + + for report:GdUnitTestSuiteReport in _reports: + if report.get_resource_path() == p_resource_path: + report.add_testcase_reports(p_test_name, p_reports) + + +func add_testsuite_report(p_resource_path: String, p_suite_name: String, p_test_count: int) -> void: + _reports.append(GdUnitTestSuiteReport.new(p_resource_path, p_suite_name, p_test_count, _text_formatter)) + + +func add_testsuite_reports( + p_resource_path: String, + p_reports: Array = []) -> void: + + for report:GdUnitTestSuiteReport in _reports: + if report.get_resource_path() == p_resource_path: + report.set_reports(p_reports) + + +func set_counters( + p_resource_path: String, + p_test_name: String, + p_error_count: int, + p_failure_count: int, + p_orphan_count: int, + p_is_skipped: bool, + p_is_flaky: bool, + p_duration: int) -> void: + + for report: GdUnitTestSuiteReport in _reports: + if report.get_resource_path() == p_resource_path: + report.set_testcase_counters(p_test_name, p_error_count, p_failure_count, p_orphan_count, + p_is_skipped, p_is_flaky, p_duration) + + +func update_testsuite_counters( + p_resource_path: String, + p_error_count: int, + p_failure_count: int, + p_orphan_count: int, + p_skipped_count: int, + p_flaky_count: int, + p_duration: int) -> void: + + for report:GdUnitTestSuiteReport in _reports: + if report.get_resource_path() == p_resource_path: + report._update_testsuite_counters(p_error_count, p_failure_count, p_orphan_count, p_skipped_count, p_flaky_count, p_duration) + _update_summary_counters(p_error_count, p_failure_count, p_orphan_count, p_skipped_count, p_flaky_count, 0) + + +func _update_summary_counters( + p_error_count: int, + p_failure_count: int, + p_orphan_count: int, + p_skipped_count: int, + p_flaky_count: int, + p_duration: int) -> void: + + _error_count += p_error_count + _failure_count += p_failure_count + _orphan_count += p_orphan_count + _skipped_count += p_skipped_count + _flaky_count += p_flaky_count + _duration += p_duration + + +func calculate_state(p_error_count :int, p_failure_count :int, p_orphan_count :int, p_flaky_count: int, p_skipped_count: int) -> String: + if p_error_count > 0: + return "ERROR" + if p_failure_count > 0: + return "FAILED" + if p_flaky_count > 0: + return "FLAKY" + if p_orphan_count > 0: + return "WARNING" + if p_skipped_count > 0: + return "SKIPPED" + return "PASSED" + + +func calculate_succes_rate(p_test_count :int, p_error_count :int, p_failure_count :int) -> String: + if p_failure_count == 0: + return "100%" + var count := p_test_count-p_failure_count-p_error_count + if count < 0: + return "0%" + return "%d" % (( 0 if count < 0 else count) * 100.0 / p_test_count) + "%" + + +func create_summary(_report_dir :String) -> String: + return "" diff --git a/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid new file mode 100644 index 0000000..102ea6c --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid @@ -0,0 +1 @@ +uid://cpa28jej5y0l diff --git a/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd new file mode 100644 index 0000000..bf4c96e --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd @@ -0,0 +1,12 @@ +class_name GdUnitReportWriter +extends RefCounted + + +func write(_report_path: String, _report: GdUnitReportSummary) -> String: + assert(false, "'write' is not implemented!") + return "" + + +func output_format() -> String: + assert(false, "'output_format' is not implemented!") + return "" diff --git a/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid new file mode 100644 index 0000000..a3169d3 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid @@ -0,0 +1 @@ +uid://dpm583je0eod4 diff --git a/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd new file mode 100644 index 0000000..b64f0e7 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd @@ -0,0 +1,49 @@ +class_name GdUnitTestCaseReport +extends GdUnitReportSummary + + +var _suite_name: String +var _failure_reports: Array[GdUnitReport] = [] + + +func _init(p_resource_path: String, p_suite_name: String, p_test_name: String, text_formatter: Callable) -> void: + _resource_path = p_resource_path + _suite_name = p_suite_name + _name = p_test_name + _text_formatter = text_formatter + + +func suite_name() -> String: + return _suite_name + + +func failure_report() -> String: + var report_message := "" + for report in get_test_reports(): + var stack_trace := "" if report.stack_trace() == null else report.stack_trace().print_stack_trace() + var message := "%s\n%s\n" % [report.message(), stack_trace] + report_message += _text_formatter.call(message) + return report_message + + +func add_testcase_reports(reports: Array[GdUnitReport]) -> void: + _failure_reports.append_array(reports) + + +func set_testcase_counters( + p_error_count: int, + p_failure_count: int, + p_orphan_count: int, + p_is_skipped: bool, + p_is_flaky: bool, + p_duration: int) -> void: + _error_count = p_error_count + _failure_count = p_failure_count + _orphan_count = p_orphan_count + _skipped_count = p_is_skipped + _flaky_count = p_is_flaky as int + _duration = p_duration + + +func get_test_reports() -> Array[GdUnitReport]: + return _failure_reports diff --git a/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid new file mode 100644 index 0000000..341e564 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid @@ -0,0 +1 @@ +uid://c25dpg8o2270t diff --git a/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd new file mode 100644 index 0000000..46b8193 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd @@ -0,0 +1,112 @@ +class_name GdUnitTestReporter +extends RefCounted + + +var _statistics := {} +var _summary := {} + + +func init_summary() -> void: + _summary["suite_count"] = 0 + _summary["total_count"] = 0 + _summary["error_count"] = 0 + _summary["failed_count"] = 0 + _summary["skipped_count"] = 0 + _summary["flaky_count"] = 0 + _summary["orphan_nodes"] = 0 + _summary["elapsed_time"] = 0 + + +func init_statistics() -> void: + _statistics.clear() + + +func add_test_statistics(event: GdUnitEvent) -> void: + _statistics[event.guid()] = { + "error_count" : event.error_count(), + "failed_count" : event.failed_count(), + "skipped_count" : event.skipped_count(), + "flaky_count" : event.is_flaky() as int, + "orphan_nodes" : event.orphan_nodes() + } + + +func build_test_suite_statisitcs(event: GdUnitEvent) -> Dictionary: + var statistic := { + "total_count" : _statistics.size(), + "error_count" : event.error_count(), + "failed_count" : event.failed_count(), + "skipped_count" : event.skipped_count(), + "flaky_count" : 0, + "orphan_nodes" : event.orphan_nodes() + } + _summary["suite_count"] += 1 + _summary["total_count"] += _statistics.size() + _summary["error_count"] += event.error_count() + _summary["failed_count"] += event.failed_count() + _summary["skipped_count"] += event.skipped_count() + _summary["orphan_nodes"] += event.orphan_nodes() + _summary["elapsed_time"] += event.elapsed_time() + + for key: String in ["error_count", "failed_count", "skipped_count", "flaky_count", "orphan_nodes"]: + var value: int = _statistics.values().reduce(get_value.bind(key), 0 ) + statistic[key] += value + _summary[key] += value + + return statistic + + +func get_value(acc: int, value: Dictionary, key: String) -> int: + return acc + value[key] + + +func processed_suite_count() -> int: + return _summary["suite_count"] + + +func total_test_count() -> int: + return _summary["total_count"] + + +func total_flaky_count() -> int: + return _summary["flaky_count"] + + +func total_error_count() -> int: + return _summary["error_count"] + + +func total_failure_count() -> int: + return _summary["failed_count"] + + +func total_skipped_count() -> int: + return _summary["skipped_count"] + + +func total_orphan_count() -> int: + return _summary["orphan_nodes"] + + +func elapsed_time() -> int: + return _summary["elapsed_time"] + + +func error_count(statistics: Dictionary) -> int: + return statistics["error_count"] + + +func failed_count(statistics: Dictionary) -> int: + return statistics["failed_count"] + + +func orphan_nodes(statistics: Dictionary) -> int: + return statistics["orphan_nodes"] + + +func skipped_count(statistics: Dictionary) -> int: + return statistics["skipped_count"] + + +func flaky_count(statistics: Dictionary) -> int: + return statistics["flaky_count"] diff --git a/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid new file mode 100644 index 0000000..1b9f252 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid @@ -0,0 +1 @@ +uid://dp7l2bmvaiqc8 diff --git a/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd new file mode 100644 index 0000000..9be0682 --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd @@ -0,0 +1,96 @@ +class_name GdUnitTestSuiteReport +extends GdUnitReportSummary + +var _time_stamp: int +var _failure_reports: Array[GdUnitReport] = [] + + +func _init(p_resource_path: String, p_name: String, p_test_count: int, text_formatter: Callable) -> void: + _resource_path = p_resource_path + _name = p_name + _test_count = p_test_count + _time_stamp = Time.get_unix_time_from_system() as int + _text_formatter = text_formatter + + +func failure_report() -> String: + var report_message := "" + for report in _failure_reports: + report_message += _text_formatter.call(str(report)) + return report_message + + +func set_duration(p_duration :int) -> void: + _duration = p_duration + + +func time_stamp() -> int: + return _time_stamp + + +func duration() -> int: + return _duration + + +func set_skipped(skipped :int) -> void: + _skipped_count += skipped + + +func set_orphans(orphans :int) -> void: + _orphan_count = orphans + + +func set_failed(count :int) -> void: + _failure_count += count + + +func set_reports(failure_reports :Array[GdUnitReport]) -> void: + _failure_reports = failure_reports + + +func add_or_create_test_report(test_report: GdUnitTestCaseReport) -> void: + _reports.append(test_report) + + +func _update_testsuite_counters( + p_error_count: int, + p_failure_count: int, + p_orphan_count: int, + p_skipped_count: int, + p_flaky_count: int, + p_duration: int) -> void: + _error_count += p_error_count + _failure_count += p_failure_count + _orphan_count += p_orphan_count + _skipped_count += p_skipped_count + _flaky_count += p_flaky_count + _duration += p_duration + + +func set_testcase_counters( + test_name: String, + p_error_count: int, + p_failure_count: int, + p_orphan_count: int, + p_is_skipped: bool, + p_is_flaky: bool, + p_duration: int) -> void: + if _reports.is_empty(): + return + var test_report: GdUnitTestCaseReport = _reports.filter(func (report: GdUnitTestCaseReport) -> bool: + return report.name() == test_name + ).back() + if test_report: + test_report.set_testcase_counters(p_error_count, p_failure_count, p_orphan_count, p_is_skipped, p_is_flaky, p_duration) + + +func add_testcase_reports(test_name: String, reports: Array[GdUnitReport]) -> void: + if reports.is_empty(): + return + # we lookup to latest matching report because of flaky tests could be retry the tests + # and resultis in multipe report entries with the same name + var test_report: GdUnitTestCaseReport = _reports.filter(func (report: GdUnitTestCaseReport) -> bool: + return report.name() == test_name + ).back() + if test_report: + test_report.add_testcase_reports(reports) diff --git a/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid new file mode 100644 index 0000000..345846e --- /dev/null +++ b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid @@ -0,0 +1 @@ +uid://bo2ddxaneo5n4 diff --git a/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd new file mode 100644 index 0000000..5ee7cdc --- /dev/null +++ b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd @@ -0,0 +1,233 @@ +@tool +class_name GdUnitConsoleTestReporter + + +var test_session: GdUnitTestSession: + get: + return test_session + set(value): + # disconnect first possible connected listener + if test_session != null: + test_session.test_event.disconnect(on_gdunit_event) + # add listening to current session + test_session = value + if test_session != null: + test_session.test_event.connect(on_gdunit_event) + + +var _writer: GdUnitMessageWriter +var _reporter: GdUnitTestReporter = GdUnitTestReporter.new() +var _status_indent := 86 +var _detailed: bool + + +func _init(writer: GdUnitMessageWriter, detailed := false) -> void: + _writer = writer + _writer.clear() + _detailed = detailed + if _detailed: + _status_indent = 20 + + +func clear() -> void: + _writer.clear() + + +func on_gdunit_event(event: GdUnitEvent) -> void: + match event.type(): + GdUnitEvent.INIT: + _reporter.init_summary() + + GdUnitEvent.STOP: + _print_summary() + println_message(build_executed_test_suite_msg(processed_suite_count(), processed_suite_count()), Color.DARK_SALMON) + println_message(build_executed_test_case_msg(total_test_count(), total_skipped_count()), Color.DARK_SALMON) + println_message("Total execution time: %s" % LocalTime.elapsed(elapsed_time()), Color.DARK_SALMON) + # We need finally to set the wave effect to enable the animations + _writer.effect(GdUnitMessageWriter.Effect.WAVE).print_at("", 0) + + GdUnitEvent.TESTSUITE_BEFORE: + _reporter.init_statistics() + print_message("Run Test Suite: ", Color.DARK_TURQUOISE) + println_message(event.resource_path(), GdUnitEditorColorTheme.engine_type_color) + + GdUnitEvent.TESTSUITE_AFTER: + if not event.reports().is_empty(): + _writer.indent(1).color(GdUnitEditorColorTheme.engine_type_color).print_message(event._suite_name) + print_message(" > ") + print_message("finalize()", GdUnitEditorColorTheme.function_definition_color) + _writer.indent(-1) + _print_failure_report(event.reports()) + _print_statistics(_reporter.build_test_suite_statisitcs(event)) + _print_status(event) + println_message("") + if _detailed: + println_message("") + + GdUnitEvent.TESTCASE_BEFORE: + var test := test_session.find_test_by_id(event.guid()) + _print_test_path(test, event.guid()) + if _detailed: + _writer.color(Color.FOREST_GREEN).print_at("STARTED", _status_indent) + println_message("") + + GdUnitEvent.TESTCASE_AFTER: + _reporter.add_test_statistics(event) + if _detailed: + var test := test_session.find_test_by_id(event.guid()) + _print_test_path(test, event.guid()) + _print_status(event) + _writer.indent(-1) + _print_failure_report(event.reports()) + if _detailed: + println_message("") + + +func _print_test_path(test: GdUnitTestCase, uid: GdUnitGUID) -> void: + if test == null: + prints_warning("Can't print full test info, the test by uid: '%s' was not discovered." % uid) + _writer.indent(1).color(GdUnitEditorColorTheme.engine_type_color).print_message("Test ID: %s" % uid) + return + + var suite_name := test.source_file if _detailed else test.suite_name + _writer.indent(1).color(GdUnitEditorColorTheme.engine_type_color).print_message(suite_name) + print_message(" > ") + print_message(test.display_name, GdUnitEditorColorTheme.function_definition_color) + + +func _print_status(event: GdUnitEvent) -> void: + if event.is_flaky() and event.is_success(): + var retries: int = event.statistic(GdUnitEvent.RETRY_COUNT) + _writer.color(Color.GREEN_YELLOW) \ + .style(GdUnitMessageWriter.ITALIC) \ + .print_at("FLAKY (%d retries)" % retries, _status_indent) + elif event.is_success(): + _writer.color(Color.FOREST_GREEN).print_at("PASSED", _status_indent) + elif event.is_skipped(): + _writer.color(Color.GOLDENROD).style(GdUnitMessageWriter.ITALIC).print_at("SKIPPED", _status_indent) + elif event.is_failed() or event.is_error(): + var retries: int = event.statistic(GdUnitEvent.RETRY_COUNT) + var message := "FAILED (retry %d)" % retries if retries > 1 else "FAILED" + _writer.color(Color.FIREBRICK) \ + .style(GdUnitMessageWriter.BOLD) \ + .effect(GdUnitMessageWriter.Effect.WAVE) \ + .print_at(message, _status_indent) + elif event.is_warning(): + _writer.color(Color.GOLDENROD) \ + .style(GdUnitMessageWriter.UNDERLINE) \ + .print_at("WARNING", _status_indent) + + println_message(" %s" % LocalTime.elapsed(event.elapsed_time()), Color.CORNFLOWER_BLUE) + + +func _print_failure_report(reports: Array[GdUnitReport]) -> void: + if reports.is_empty(): + return + + _writer.indent(1) \ + .color(Color.DARK_TURQUOISE) \ + .style(GdUnitMessageWriter.BOLD | GdUnitMessageWriter.UNDERLINE) \ + .println_message("Report:") + + for report in reports: + if ( + report.is_failure() + or report.is_error() + or report.is_warning() + or report.is_skipped() + or report.is_orphan() + ): + _writer.indent(1) \ + .color(GdUnitEditorColorTheme.text_color) \ + .print_message(report.message()) \ + .print_stack_trace(report.stack_trace()) + _writer.indent(-1) + _writer.indent(-1) + + + if not reports.is_empty(): + println_message("") + + +func _print_statistics(statistics: Dictionary) -> void: + print_message("Statistics:", Color.DODGER_BLUE) + print_message(" %d test cases | %d errors | %d failures | %d flaky | %d skipped | %d orphans |" % \ + [statistics["total_count"], + statistics["error_count"], + statistics["failed_count"], + statistics["flaky_count"], + statistics["skipped_count"], + statistics["orphan_nodes"]]) + + +func _print_summary() -> void: + print_message("Overall Summary:", Color.DODGER_BLUE) + _writer \ + .println_message(" %d test cases | %d errors | %d failures | %d flaky | %d skipped | %d orphans |" % [ + total_test_count(), + total_error_count(), + total_failure_count(), + total_flaky_count(), + total_skipped_count(), + total_orphan_count() + ]) + + +func build_executed_test_suite_msg(executed_count: int, total_count: int) -> String: + if executed_count == total_count: + return "Executed test suites: (%d/%d)" % [executed_count, total_count] + return "Executed test suites: (%d/%d), %d skipped" % [executed_count, total_count, (total_count - executed_count)] + + +func build_executed_test_case_msg(total_count: int, p_skipped_count: int) -> String: + if p_skipped_count == 0: + return "Executed test cases : (%d/%d)" % [total_count, total_count] + return "Executed test cases : (%d/%d), %d skipped" % [total_count - p_skipped_count, total_count, p_skipped_count] + + +func print_message(message: String, color: Color = GdUnitEditorColorTheme.text_color) -> void: + _writer.color(color).print_message(message) + + +func println_message(message: String, color: Color = GdUnitEditorColorTheme.text_color) -> void: + _writer.color(color).println_message(message) + + +func prints_warning(message: String) -> void: + _writer.prints_warning(message) + + +func prints_error(message: String) -> void: + _writer.prints_error(message) + + +func total_test_count() -> int: + return _reporter.total_test_count() + + +func total_error_count() -> int: + return _reporter.total_error_count() + + +func total_failure_count() -> int: + return _reporter.total_failure_count() + + +func total_flaky_count() -> int: + return _reporter.total_flaky_count() + + +func total_skipped_count() -> int: + return _reporter.total_skipped_count() + + +func total_orphan_count() -> int: + return _reporter.total_orphan_count() + + +func processed_suite_count() -> int: + return _reporter.processed_suite_count() + + +func elapsed_time() -> int: + return _reporter.elapsed_time() diff --git a/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid new file mode 100644 index 0000000..df495ad --- /dev/null +++ b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid @@ -0,0 +1 @@ +uid://d20tsvagd6342 diff --git a/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd new file mode 100644 index 0000000..079b6df --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd @@ -0,0 +1,55 @@ +class_name GdUnitByPathReport +extends GdUnitReportSummary + + +func _init(p_path: String, report_summaries: Array[GdUnitReportSummary]) -> void: + _resource_path = p_path + _reports = report_summaries + + +# -> Dictionary[String, Array[GdUnitReportSummary]] +static func sort_reports_by_path(report_summaries: Array[GdUnitReportSummary]) -> Dictionary: + var by_path := Dictionary() + for report in report_summaries: + var suite_path: String = ProjectSettings.localize_path(report.path()) + var suite_report: Array[GdUnitReportSummary] = by_path.get(suite_path, [] as Array[GdUnitReportSummary]) + suite_report.append(report) + by_path[suite_path] = suite_report + return by_path + + +func path() -> String: + return _resource_path.replace("res://", "").trim_suffix("/") + + +func create_record(report_link: String) -> String: + return GdUnitHtmlPatterns.build(GdUnitHtmlPatterns.TABLE_RECORD_PATH, self, report_link) + + +func write(report_dir: String) -> String: + calculate_summary() + var output_path := GdUnitHtmlPatterns.create_path_output_path(report_dir, path()) + var template := GdUnitHtmlPatterns.load_template("res://addons/gdUnit4/src/reporters/html/template/folder_report.html") + var path_report := GdUnitHtmlPatterns.build(template, self, output_path, GdUnitHtmlPatterns.get_path_as_link(self)) + path_report = apply_testsuite_reports(report_dir, path_report, _reports) + GdUnitHtmlPatterns.write_html_file(output_path, path_report) + return output_path + + +func apply_testsuite_reports(report_dir: String, template: String, test_suite_reports: Array[GdUnitReportSummary]) -> String: + var table_records := PackedStringArray() + for report: GdUnitTestSuiteReport in test_suite_reports: + var report_link := GdUnitHtmlPatterns.create_suite_output_path(report_dir, report.path(), report.name()).replace(report_dir, "..") + @warning_ignore("return_value_discarded") + table_records.append(GdUnitHtmlPatterns.create_suite_record(report_link, report)) + return template.replace(GdUnitHtmlPatterns.TABLE_BY_TESTSUITES, "\n".join(table_records)) + + +func calculate_summary() -> void: + for report: GdUnitTestSuiteReport in get_reports(): + _error_count += report.error_count() + _failure_count += report.failure_count() + _orphan_count += report.orphan_count() + _skipped_count += report.skipped_count() + _flaky_count += report.flaky_count() + _duration += report.duration() diff --git a/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid new file mode 100644 index 0000000..52c9d49 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid @@ -0,0 +1 @@ +uid://c4okyuswi87dp diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlEncoder.gd b/addons/gdUnit4/src/reporters/html/GdUnitHtmlEncoder.gd new file mode 100644 index 0000000..c6f02ca --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlEncoder.gd @@ -0,0 +1,12 @@ +class_name GdUnitHtmlEncoder +extends RefCounted + + +static func encode(value: String) -> String: + # & must be replaced first to avoid double-encoding the entities we insert + return value\ + .replace("&", "&")\ + .replace("<", "<")\ + .replace(">", ">")\ + .replace('"', """)\ + .replace("'", "'") diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlEncoder.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitHtmlEncoder.gd.uid new file mode 100644 index 0000000..44e66cc --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlEncoder.gd.uid @@ -0,0 +1 @@ +uid://e4injgtawte6 diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd new file mode 100644 index 0000000..2401180 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd @@ -0,0 +1,209 @@ +class_name GdUnitHtmlPatterns +extends RefCounted + +const TABLE_RECORD_TESTSUITE = """ + + ${testsuite_name} + ${report_state_label} + ${test_count} + ${skipped_count} + ${flaky_count} + ${failure_count} + ${orphan_count} + ${duration} + +
+
+
+
+
+
+
+
+ + +""" + +const TABLE_RECORD_PATH = """ + + ${path} + ${report_state_label} + ${test_count} + ${skipped_count} + ${flaky_count} + ${failure_count} + ${orphan_count} + ${duration} + +
+
+
+
+
+
+
+
+ + +""" + + +const TABLE_REPORT_TESTSUITE = """ + + TestSuite hooks + n/a + ${orphan_count} + ${duration} + +
+${failure-report}
+										
+ + +""" + + +const TABLE_RECORD_TESTCASE = """ + + ${testcase_name} + ${report_state_label} + ${skipped_count} + ${orphan_count} + ${duration} + +
+${failure-report}
+										
+ + +""" + + +const TABLE_BY_PATHS = "${report_table_paths}" +const TABLE_BY_TESTSUITES = "${report_table_testsuites}" +const TABLE_BY_TESTCASES = "${report_table_tests}" + +# the report state success, error, warning +const REPORT_STATE = "${report_state}" +const REPORT_STATE_LABEL = "${report_state_label}" +const PATH = "${path}" +const RESOURCE_PATH = "${resource_path}" +const TESTSUITE_COUNT = "${suite_count}" +const TESTCASE_COUNT = "${test_count}" +const FAILURE_COUNT = "${failure_count}" +const FLAKY_COUNT = "${flaky_count}" +const SKIPPED_COUNT = "${skipped_count}" +const ORPHAN_COUNT = "${orphan_count}" +const DURATION = "${duration}" +const FAILURE_REPORT = "${failure-report}" +const SUCCESS_PERCENT = "${success_percent}" + + +const QUICK_STATE_SKIPPED = "${skipped-percent}" +const QUICK_STATE_PASSED = "${passed-percent}" +const QUICK_STATE_FLAKY = "${flaky-percent}" +const QUICK_STATE_ERROR = "${error-percent}" +const QUICK_STATE_FAILED = "${failed-percent}" +const QUICK_STATE_WARNING = "${warning-percent}" + +const TESTSUITE_NAME = "${testsuite_name}" +const TESTCASE_NAME = "${testcase_name}" +const REPORT_LINK = "${report_link}" +const BREADCRUMP_PATH_LINK = "${breadcrumb_path_link}" +const BUILD_DATE = "${buid_date}" + + +static func current_date() -> String: + return Time.get_datetime_string_from_system(true, true) + + +static func build(template: String, report: GdUnitReportSummary, report_link: String, breadcrumb_path_link := "") -> String: + return template\ + .replace(PATH, get_report_path(report))\ + .replace(BREADCRUMP_PATH_LINK, breadcrumb_path_link)\ + .replace(RESOURCE_PATH, report.get_resource_path())\ + .replace(TESTSUITE_NAME, GdUnitHtmlEncoder.encode(report.name()))\ + .replace(TESTSUITE_COUNT, str(report.suite_count()))\ + .replace(TESTCASE_COUNT, str(report.test_count()))\ + .replace(FAILURE_COUNT, str(report.error_count() + report.failure_count()))\ + .replace(FLAKY_COUNT, str(report.flaky_count()))\ + .replace(SKIPPED_COUNT, str(report.skipped_count()))\ + .replace(ORPHAN_COUNT, str(report.orphan_count()))\ + .replace(DURATION, LocalTime.elapsed(report.duration()))\ + .replace(SUCCESS_PERCENT, report.calculate_succes_rate(report.test_count(), report.error_count(), report.failure_count()))\ + .replace(REPORT_STATE, report.report_state().to_lower())\ + .replace(REPORT_STATE_LABEL, report.report_state())\ + .replace(QUICK_STATE_SKIPPED, calculate_percentage(report.test_count(), report.skipped_count()))\ + .replace(QUICK_STATE_PASSED, calculate_percentage(report.test_count(), report.success_count()))\ + .replace(QUICK_STATE_FLAKY, calculate_percentage(report.test_count(), report.flaky_count()))\ + .replace(QUICK_STATE_ERROR, calculate_percentage(report.test_count(), report.error_count()))\ + .replace(QUICK_STATE_FAILED, calculate_percentage(report.test_count(), report.failure_count()))\ + .replace(QUICK_STATE_WARNING, calculate_percentage(report.test_count(), 0))\ + .replace(REPORT_LINK, report_link)\ + .replace(BUILD_DATE, current_date()) + + +static func load_template(template_name: String) -> String: + return FileAccess.open(template_name, FileAccess.READ).get_as_text() + + +static func normalize_path(path: String) -> String: + return path.replace("/", ".").replace(" ", "_") + + +static func create_suite_output_path(report_dir: String, path: String, name: String) -> String: + return "%s/test_suites/%s.%s.html" % [report_dir, normalize_path(path), name] + + +static func create_path_output_path(report_dir: String, path: String) -> String: + return "%s/path/%s.html" % [report_dir, normalize_path(path)] + + +static func write_html_file(output_path: String, content: String) -> void: + var dir := output_path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir): + @warning_ignore("return_value_discarded") + DirAccess.make_dir_recursive_absolute(dir) + FileAccess.open(output_path, FileAccess.WRITE).store_string(content) + + +static func get_path_as_link(report: GdUnitReportSummary) -> String: + return "../path/%s.html" % normalize_path(report.path()) + + +static func get_report_path(report: GdUnitReportSummary) -> String: + var path := report.path() + if path.is_empty(): + return "/" + return path + + +static func calculate_percentage(p_test_count: int, count: int) -> String: + if count <= 0: + return "0%" + return "%d" % (( 0 if count < 0 else count) * 100.0 / p_test_count) + "%" + + + +static func create_suite_record(report_link: String, report: GdUnitTestSuiteReport) -> String: + return GdUnitHtmlPatterns.build(GdUnitHtmlPatterns.TABLE_RECORD_TESTSUITE, report, report_link) + + +static func create_test_failure_report(_report_dir: String, report: GdUnitTestCaseReport) -> String: + return GdUnitHtmlPatterns.TABLE_RECORD_TESTCASE\ + .replace(GdUnitHtmlPatterns.REPORT_STATE, report.report_state().to_lower())\ + .replace(GdUnitHtmlPatterns.REPORT_STATE_LABEL, report.report_state())\ + .replace(GdUnitHtmlPatterns.TESTCASE_NAME, report.name())\ + .replace(GdUnitHtmlPatterns.SKIPPED_COUNT, str(report.skipped_count()))\ + .replace(GdUnitHtmlPatterns.ORPHAN_COUNT, str(report.orphan_count()))\ + .replace(GdUnitHtmlPatterns.DURATION, LocalTime.elapsed(report._duration))\ + .replace(GdUnitHtmlPatterns.FAILURE_REPORT, GdUnitHtmlEncoder.encode(report.failure_report())) + + +static func create_suite_failure_report(report: GdUnitTestSuiteReport) -> String: + return GdUnitHtmlPatterns.TABLE_REPORT_TESTSUITE\ + .replace(GdUnitHtmlPatterns.REPORT_STATE, report.report_state().to_lower())\ + .replace(GdUnitHtmlPatterns.REPORT_STATE_LABEL, report.report_state())\ + .replace(GdUnitHtmlPatterns.ORPHAN_COUNT, str(report.orphan_count()))\ + .replace(GdUnitHtmlPatterns.DURATION, LocalTime.elapsed(report._duration))\ + .replace(GdUnitHtmlPatterns.FAILURE_REPORT, GdUnitHtmlEncoder.encode(report.failure_report())) diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid new file mode 100644 index 0000000..21cef0c --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid @@ -0,0 +1 @@ +uid://bf57kgwtoouyr diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd new file mode 100644 index 0000000..ea7c5aa --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd @@ -0,0 +1,63 @@ +class_name GdUnitHtmlReportWriter +extends GdUnitReportWriter + + +func output_format() -> String: + return "HTML" + + +func write(report_path: String, report: GdUnitReportSummary) -> String: + var template := GdUnitHtmlPatterns.load_template("res://addons/gdUnit4/src/reporters/html/template/index.html") + var to_write := GdUnitHtmlPatterns.build(template, report, "") + to_write = _apply_path_reports(report_path, to_write, report.get_reports()) + to_write = _apply_testsuite_reports(report_path, to_write, report.get_reports()) + # write report + DirAccess.make_dir_recursive_absolute(report_path) + var html_report_file := "%s/index.html" % report_path + FileAccess.open(html_report_file, FileAccess.WRITE).store_string(to_write) + @warning_ignore("return_value_discarded") + GdUnitFileAccess.copy_directory("res://addons/gdUnit4/src/reporters/html/template/css/", report_path + "/css") + return html_report_file + + +func _apply_path_reports(report_dir: String, template: String, report_summaries: Array) -> String: + # Dictionary[String, Array[GdUnitReportSummary]] + var path_report_mapping := GdUnitByPathReport.sort_reports_by_path(report_summaries) + var table_records := PackedStringArray() + var paths: Array[String] = [] + paths.append_array(path_report_mapping.keys()) + paths.sort() + for report_at_path in paths: + var reports: Array[GdUnitReportSummary] = path_report_mapping.get(report_at_path) + var report := GdUnitByPathReport.new(report_at_path, reports) + var report_link: String = report.write(report_dir).replace(report_dir, ".") + @warning_ignore("return_value_discarded") + table_records.append(report.create_record(report_link)) + return template.replace(GdUnitHtmlPatterns.TABLE_BY_PATHS, "\n".join(table_records)) + + +func _apply_testsuite_reports(report_dir: String, template: String, test_suite_reports: Array[GdUnitReportSummary]) -> String: + var table_records := PackedStringArray() + for report: GdUnitTestSuiteReport in test_suite_reports: + var report_link: String = _write(report_dir, report).replace(report_dir, ".") + @warning_ignore("return_value_discarded") + table_records.append(GdUnitHtmlPatterns.create_suite_record(report_link, report)) + return template.replace(GdUnitHtmlPatterns.TABLE_BY_TESTSUITES, "\n".join(table_records)) + + +func _write(report_dir: String, report: GdUnitTestSuiteReport) -> String: + var template := GdUnitHtmlPatterns.load_template("res://addons/gdUnit4/src/reporters/html/template/suite_report.html") + template = GdUnitHtmlPatterns.build(template, report, "", GdUnitHtmlPatterns.get_path_as_link(report)) + + var report_output_path := GdUnitHtmlPatterns.create_suite_output_path(report_dir, report.path(), report.name()) + var test_report_table := PackedStringArray() + if not report._failure_reports.is_empty(): + @warning_ignore("return_value_discarded") + test_report_table.append(GdUnitHtmlPatterns.create_suite_failure_report(report)) + for test_report: GdUnitTestCaseReport in report._reports: + @warning_ignore("return_value_discarded") + test_report_table.append(GdUnitHtmlPatterns.create_test_failure_report(report_output_path, test_report)) + + template = template.replace(GdUnitHtmlPatterns.TABLE_BY_TESTCASES, "\n".join(test_report_table)) + GdUnitHtmlPatterns.write_html_file(report_output_path, template) + return report_output_path diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid new file mode 100644 index 0000000..4f3ea03 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid @@ -0,0 +1 @@ +uid://cpjxd42i6c1yy diff --git a/addons/gdUnit4/src/reporters/html/template/.gdignore b/addons/gdUnit4/src/reporters/html/template/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css b/addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css new file mode 100644 index 0000000..17215ff --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css @@ -0,0 +1,66 @@ +.breadcrumb { + display: flex; + border-radius: 6px; + overflow: hidden; + height: 45px; + z-index: 1; + background-color: #9d73eb; + margin-top: 0px; + margin-bottom: 10px; + box-shadow: 0 0 3px black; +} + +.breadcrumb a { + position: relative; + display: flex; + -ms-flex-positive: 1; + flex-grow: 1; + text-decoration: none; + margin: auto; + height: 100%; + color: white; +} + +.breadcrumb a:first-child { + padding-left: 5.2px; +} + +.breadcrumb a:last-child { + padding-right: 5.2px; +} + +.breadcrumb a:after { + content: ""; + position: absolute; + display: inline-block; + width: 45px; + height: 45px; + top: 0; + right: -20px; + background-color: #9d73eb; + border-top-right-radius: 5px; + transform: scale(0.707) rotate(45deg); + box-shadow: 2px -2px rgba(0, 0, 0, 0.25); + z-index: 1; +} + +.breadcrumb a:last-child:after { + content: none; +} + +.breadcrumb a.active, +.breadcrumb a:hover { + background: #b899f2; + color: white; + text-decoration: underline; +} + +.breadcrumb a.active:after, +.breadcrumb a:hover:after { + background: #b899f2; +} + +.breadcrumb span { + margin: inherit; + z-index: 2; +} diff --git a/addons/gdUnit4/src/reporters/html/template/css/logo.png b/addons/gdUnit4/src/reporters/html/template/css/logo.png new file mode 100644 index 0000000..12de79f Binary files /dev/null and b/addons/gdUnit4/src/reporters/html/template/css/logo.png differ diff --git a/addons/gdUnit4/src/reporters/html/template/css/styles.css b/addons/gdUnit4/src/reporters/html/template/css/styles.css new file mode 100644 index 0000000..e92d59b --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/template/css/styles.css @@ -0,0 +1,475 @@ +html, +body { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + font-family: sans-serif; + background-color: white; + height: 100%; +} + +main { + flex-grow: 1; + overflow: auto; + margin: 0 10em; +} + + +header { + color: white; + padding: 1px; + position: relative; + background-image: linear-gradient(to bottom right, #8058e3, #9d73eb); +} + +.logo { + position: fixed; + top: 20px; + left: 20px; + display: flex; + align-items: center; + z-index: 1000; + filter: grayscale(1); + mix-blend-mode: plus-lighter; +} + +.logo img { + width: 64px; + height: 64px; +} + +.logo span { + font-size: 1.2em; + color: lightslategray; +} + +.report-container { + margin: 0 15em; + text-align: center; + margin-top: 60px; + flex-grow: 0; +} + +h1 { + margin: 0 0 20px 0; + font-size: 2.5em; + font-weight: normal; +} + +.summary { + display: inline-flex; + justify-content: center; + flex-wrap: nowrap; + margin-bottom: 20px; + align-items: baseline; + max-width: 960px; +} + +.summary-item { + flex: 1; + min-width: 80px; +} + +.label { + font-size: 1em; + flex-wrap: nowrap; +} + +.value { + font-size: 0.9em; + display: block; + padding-top: 10px; + color: lightgray; +} + +.success-rate { + padding-left: 40px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.check-icon { + background-color: #34c538; + color: white; + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4em; +} + +.rate-text { + text-align: center; + flex-wrap: nowrap; +} + +.percentage { + font-size: 1.2em; + font-weight: bold; +} + + +nav { + padding: 20px 0px; + font-family: monospace; +} + +nav ul { + list-style-type: none; + padding: 0; + margin: 0; + display: flex; + justify-content: flex-start; + border-bottom: 1px solid lightgray; +} + +nav li { + cursor: pointer; + padding: 5px 20px; + font-size: 1.1em; + color: lightslategray; +} + +nav li.active { + color: darkslategray; + border-bottom: 1px solid darkslategray; + font-weight: bold; +} + +div#content { + height: calc(100vh - 400px); +} + + +table { + width: 100%; + height: 100%; + border-collapse: collapse; + overflow: hidden; +} + +thead th { + position: sticky; + top: 0; + background-color: white; + z-index: 1; + border-bottom: 2px solid #ddd; +} + +tbody { + display: block; + /* Limit the height of the table body */ + max-height: calc(100vh - 400px); + /* Enable scrolling on the table body */ + overflow-y: auto; +} + +thead, +tbody tr { + display: table; + width: 100%; + table-layout: fixed; +} + +tbody td { + overflow: hidden; +} + +/* Ensure scrollbar visibility */ +tbody::-webkit-scrollbar { + height: 4px; + width: 14px; +} + +tbody::-webkit-scrollbar-thumb { + background-color: #aaa6a6; + border-radius: 4px; +} + +tbody::-webkit-scrollbar-track { + background-color: #f1f1f1; +} + +th, +td { + font-size: .9em; + padding: 5px 0px; + border-bottom: 1px solid #eee; + color: lightslategrey; + text-align: left; + text-wrap: nowrap; + /* Default max and min width for all columns */ + max-width: 150px; + min-width: 80px; + width: 80px; +} + +th { + font-size: 1em; + font-weight: normal; + padding-top: 20px; + color: gray; + text-wrap: nowrap; +} + +.tab-report { + display: grid; + grid-template-columns: 100%; + margin-bottom: 20px; +} + +.tab-report-grid { + display: grid; + grid-template-columns: 70% 30%; + margin-bottom: 20px; +} + + +/* Specific styling for the first column (Testcase) */ +th:first-child, +td:first-child { + padding-left: 5px; + text-align: left; + /* Max width for the first column */ + min-width: 249px; + width: 250px; + /* Enable scrollbar if content exceeds max-width */ + white-space: nowrap; + overflow: auto; +} + +/* Scrollbar styles for first column */ +td:first-child { + overflow-x: auto; + text-overflow: initial; +} + +/* Scrollbar appearance */ +td:first-child::-webkit-scrollbar { + height: 6px; +} + +td:first-child::-webkit-scrollbar-thumb { + background-color: #888; + border-radius: 10px; +} + +td:first-child::-webkit-scrollbar-track { + background-color: #f1f1f1; +} + +/* Max width for Result column */ +th:nth-child(2), +td:nth-child(2) { + max-width: 140px; + min-width: 140px; + width: 140px; +} + +/* Max width for Quick Results column */ +th:nth-child(9), +td:nth-child(9) { + max-width: 140px; + min-width: 140px; + width: 140px; + padding-right: 10px; +} + +/* Background color for alternating groups */ +.group-bg-1 { + background-color: #f1f1f1; +} + +.group-bg-2 { + background-color: #e0e0e0; +} + +.grid-item { + overflow: auto; + padding-left: 20px; + color: lightslategrey; + max-height: calc(100vh - 350px); +} + +div.tab td.report-column, +th.report-column { + display: none; +} + +/* Result status styles */ +.status { + padding: 2px 40px; + border-radius: 6px; + color: black; + width: 40px; + display: flex; + align-content: center; + align-items: center; +} + +.status-bar { + display: flex; + border-radius: 8px; + overflow: hidden; + height: 20px; + flex-wrap: nowrap; + justify-content: space-evenly; +} + +.status-bar-column { + margin: -2px; + color: black; + display: flex; + align-content: center; + align-items: center; + transition: width 0.3s ease; +} + +.status-skipped { + background-color: #888888; +} + +.status-passed { + background-color: #63bb38; +} + +.status-error { + background-color: #fd1100; +} + +.status-failed { + background-color: #ed594f; +} + +.status-flaky { + background-color: #1d9a1f; +} + +.status-warning { + background-color: #fdda3f; +} + +div.tab tr:hover { + background-color: #d9e7fa; + box-shadow: 0 0 5px black; +} + +div.tab tr.selected { + background-color: #d9e7fa; +} + +div.report-column { + margin-top: 10px; + width: 100%; + text-align: left; +} + +.logging-container { + width: 100%; + height: 100%; +} + +div.godot-report-frame { + margin: 10px; + font-family: monospace; + height: 100%; + background-color: #eee; +} + +div.include-footer { + position: fixed; + bottom: 0; + width: 100%; + display: flex; +} + +footer { + position: static; + left: 0; + bottom: 0; + width: 100%; + white-space: nowrap; + color: lightgray; + font-size: 12px; + background-image: linear-gradient(to bottom right, #8058e3, #9d73eb); + display: flex; + justify-content: space-between; + align-items: center; +} + +footer p { + padding-left: 10em; +} + +footer .status-legend { + display: flex; + gap: 15px; + width: 500px; +} + +footer a { + color: lightgray; +} + +footer a:hover { + color: whitesmoke; +} + +footer a:visited { + color: whitesmoke; +} + +.status-legend-item { + display: flex; + align-items: center; + gap: 5px; +} + +.status-box { + width: 15px; + height: 15px; + border-radius: 3px; + display: inline-block; +} + +/* Normal link */ +a { + color: lightslategrey; +} + +/* Link when hovered */ +a:hover { + color: #9d73eb; +} + +/* Visited link */ +a:visited { + color: #8058e3; +} + +/* Active link (while being clicked) */ +a:active { + color: #8058e3; + /* Custom color when link is clicked */ +} + + +@media (max-width: 1024px) { + .summary { + flex-direction: column; + } + + nav ul { + flex-wrap: wrap; + } + + nav li { + margin-right: 10px; + margin-bottom: 5px; + } +} diff --git a/addons/gdUnit4/src/reporters/html/template/folder_report.html b/addons/gdUnit4/src/reporters/html/template/folder_report.html new file mode 100644 index 0000000..2cdca67 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/template/folder_report.html @@ -0,0 +1,122 @@ + + + + + + + + + GdUnit4 Testsuite + + + + + +
+ +
+

Report by Paths

+
+ ${resource_path} +
+
+
+ TestSuites + ${suite_count} +
+
+ Tests + ${test_count} +
+
+ Skipped + ${skipped_count} +
+
+ Flaky + ${flaky_count} +
+
+ Failures + ${failure_count} +
+
+ Orphans + ${orphan_count} +
+
+ Duration + ${duration} +
+
+
✓
+
+ Success Rate + ${success_percent} +
+
+
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + + + + + ${report_table_testsuites} + +
TestSuitesResultTestsSkippedFlakyFailuresOrphansDurationSuccess rate
+
+
+
+
+ +
+

Generated by GdUnit4 at ${buid_date}

+
+ + Skipped + + + Passed + + + Flaky + + + Warning + + + Failed + + + Error + +
+
+ + + diff --git a/addons/gdUnit4/src/reporters/html/template/index.html b/addons/gdUnit4/src/reporters/html/template/index.html new file mode 100644 index 0000000..342c8f2 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/template/index.html @@ -0,0 +1,164 @@ + + + + + + + GdUnit4 Report + + + + +
+ +
+

Summary Report

+
+
+ Test Suites + ${suite_count} +
+
+ Tests + ${test_count} +
+
+ Skipped + ${skipped_count} +
+
+ Flaky + ${flaky_count} +
+
+ Failures + ${failure_count} +
+
+ Orphans + ${orphan_count} +
+
+ Duration + ${duration} +
+
+
✓
+
+ Success Rate + ${success_percent} +
+
+
+
+
+
+ +
+ +
+
+ +
+

Generated by GdUnit4 at ${buid_date}

+
+ + Skipped + + + Passed + + + Flaky + + + Warning + + + Failed + + + Error + +
+
+ + + + + diff --git a/addons/gdUnit4/src/reporters/html/template/suite_report.html b/addons/gdUnit4/src/reporters/html/template/suite_report.html new file mode 100644 index 0000000..4746841 --- /dev/null +++ b/addons/gdUnit4/src/reporters/html/template/suite_report.html @@ -0,0 +1,177 @@ + + + + + + + + + GdUnit4 Testsuite + + + + + + + + +
+ +
+

Testsuite Report

+
+ ${resource_path} +
+
+
+ Tests + ${test_count} +
+
+ Skipped + ${skipped_count} +
+
+ Flaky + ${flaky_count} +
+
+ Failures + ${failure_count} +
+
+ Orphans + ${orphan_count} +
+
+ Duration + ${duration} +
+
+
✓
+
+ Success Rate + ${success_percent} +
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + ${report_table_tests} + +
TestcaseResultSkippedOrphansDurationReport
+
+
+

Failure Report

+
+
+
+
+ +
+

Generated by GdUnit4 at ${buid_date}

+
+ + Skipped + + + Passed + + + Flaky + + + Warning + + + Failed + + + Error + +
+
+ + + + + diff --git a/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd new file mode 100644 index 0000000..d2484dc --- /dev/null +++ b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd @@ -0,0 +1,148 @@ +# This class implements the JUnit XML file format +# based checked https://github.com/windyroad/JUnit-Schema/blob/master/JUnit.xsd +class_name JUnitXmlReportWriter +extends GdUnitReportWriter + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") + +const ATTR_CLASSNAME := "classname" +const ATTR_ERRORS := "errors" +const ATTR_FAILURES := "failures" +const ATTR_HOST := "hostname" +const ATTR_ID := "id" +const ATTR_MESSAGE := "message" +const ATTR_NAME := "name" +const ATTR_PACKAGE := "package" +const ATTR_SKIPPED := "skipped" +const ATTR_FLAKY := "flaky" +const ATTR_TESTS := "tests" +const ATTR_TIME := "time" +const ATTR_TIMESTAMP := "timestamp" +const ATTR_TYPE := "type" + +const HEADER := '\n' + + +func output_format() -> String: + return "XML" + + +func write(report_path: String, report: GdUnitReportSummary) -> String: + var result_file: String = "%s/results.xml" % report_path + DirAccess.make_dir_recursive_absolute(report_path) + var file := FileAccess.open(result_file, FileAccess.WRITE) + if file == null: + push_warning("Can't saving the result to '%s'\n Error: %s" % [result_file, error_string(FileAccess.get_open_error())]) + else: + file.store_string(build_junit_report(report_path, report)) + return result_file + + +func build_junit_report(report_path: String, report: GdUnitReportSummary) -> String: + var iso8601_datetime := Time.get_date_string_from_system() + var test_suites := XmlElement.new("testsuites")\ + .attribute(ATTR_ID, iso8601_datetime)\ + .attribute(ATTR_NAME, report_path.get_file())\ + .attribute(ATTR_TESTS, report.test_count())\ + .attribute(ATTR_FAILURES, report.failure_count())\ + .attribute(ATTR_SKIPPED, report.skipped_count())\ + .attribute(ATTR_FLAKY, report.flaky_count())\ + .attribute(ATTR_TIME, JUnitXmlReportWriter.to_time(report.duration()))\ + .add_childs(build_test_suites(report)) + var as_string := test_suites.to_xml() + test_suites.dispose() + return HEADER + as_string + + +func build_test_suites(summary: GdUnitReportSummary) -> Array: + var test_suites: Array[XmlElement] = [] + for index in summary.get_reports().size(): + var suite_report :GdUnitTestSuiteReport = summary.get_reports()[index] + var iso8601_datetime := Time.get_datetime_string_from_unix_time(suite_report.time_stamp()) + test_suites.append(XmlElement.new("testsuite")\ + .attribute(ATTR_ID, index)\ + .attribute(ATTR_NAME, suite_report.name())\ + .attribute(ATTR_PACKAGE, suite_report.path())\ + .attribute(ATTR_TIMESTAMP, iso8601_datetime)\ + .attribute(ATTR_HOST, "localhost")\ + .attribute(ATTR_TESTS, suite_report.test_count())\ + .attribute(ATTR_FAILURES, suite_report.failure_count())\ + .attribute(ATTR_ERRORS, suite_report.error_count())\ + .attribute(ATTR_SKIPPED, suite_report.skipped_count())\ + .attribute(ATTR_FLAKY, suite_report.flaky_count())\ + .attribute(ATTR_TIME, JUnitXmlReportWriter.to_time(suite_report.duration()))\ + .add_childs(build_test_cases(suite_report))) + return test_suites + + +func build_test_cases(suite_report: GdUnitTestSuiteReport) -> Array: + var test_cases: Array[XmlElement] = [] + for index in suite_report.get_reports().size(): + var report :GdUnitTestCaseReport = suite_report.get_reports()[index] + test_cases.append( XmlElement.new("testcase")\ + .attribute(ATTR_NAME, JUnitXmlReportWriter.encode_xml(report.name()))\ + .attribute(ATTR_CLASSNAME, report.suite_name())\ + .attribute(ATTR_TIME, JUnitXmlReportWriter.to_time(report.duration()))\ + .add_childs(build_reports(report))) + return test_cases + + +func build_reports(test_report: GdUnitTestCaseReport) -> Array: + var failure_reports: Array[XmlElement] = [] + + for report: GdUnitReport in test_report.get_test_reports(): + if report.is_failure(): + failure_reports.append(XmlElement.new("failure")\ + .attribute(ATTR_MESSAGE, "FAILED: %s:%d" % [test_report.get_resource_path(), report.line_number()])\ + .attribute(ATTR_TYPE, JUnitXmlReportWriter.to_type(report.type()))\ + .text(failure_message(report))) + elif report.is_error(): + failure_reports.append(XmlElement.new("error")\ + .attribute(ATTR_MESSAGE, "ERROR: %s:%d" % [test_report.get_resource_path(), report.line_number()])\ + .attribute(ATTR_TYPE, JUnitXmlReportWriter.to_type(report.type()))\ + .text(failure_message(report))) + elif report.is_skipped(): + failure_reports.append(XmlElement.new("skipped")\ + .attribute(ATTR_MESSAGE, "SKIPPED: %s:%d" % [test_report.get_resource_path(), report.line_number()])\ + .text(convert_rtf_to_text(report.message()))) + return failure_reports + + +func failure_message(report: GdUnitReport) -> String: + var stack_trace := "" if report.stack_trace() == null else report.stack_trace().print_stack_trace() + return "%s\n%s" % [convert_rtf_to_text(report.message()), stack_trace] + + +func convert_rtf_to_text(bbcode: String) -> String: + return GdUnitTools.richtext_normalize(bbcode) + + +static func to_type(type: int) -> String: + match type: + GdUnitReport.SUCCESS: + return "SUCCESS" + GdUnitReport.WARN: + return "WARN" + GdUnitReport.FAILURE: + return "FAILURE" + GdUnitReport.ORPHAN: + return "ORPHAN" + GdUnitReport.TERMINATED: + return "TERMINATED" + GdUnitReport.INTERUPTED: + return "INTERUPTED" + GdUnitReport.ABORT: + return "ABORT" + return "UNKNOWN" + + +static func to_time(duration: int) -> String: + return "%4.03f" % (duration / 1000.0) + + +static func encode_xml(value: String) -> String: + return value.xml_escape(true) + + +#static func to_ISO8601_datetime() -> String: + #return "%04d-%02d-%02dT%02d:%02d:%02d" % [date["year"], date["month"], date["day"], date["hour"], date["minute"], date["second"]] diff --git a/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid new file mode 100644 index 0000000..0b49a32 --- /dev/null +++ b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid @@ -0,0 +1 @@ +uid://j5m2dxd3lapr diff --git a/addons/gdUnit4/src/reporters/xml/XmlElement.gd b/addons/gdUnit4/src/reporters/xml/XmlElement.gd new file mode 100644 index 0000000..86c7421 --- /dev/null +++ b/addons/gdUnit4/src/reporters/xml/XmlElement.gd @@ -0,0 +1,69 @@ +class_name XmlElement +extends RefCounted + +var _name :String +# Dictionary[String, String] +var _attributes :Dictionary = {} +var _childs :Array[XmlElement] = [] +var _parent :XmlElement = null +var _text :String = "" + + +func _init(name :String) -> void: + _name = name + + +func dispose() -> void: + for child in _childs: + child.dispose() + _childs.clear() + _attributes.clear() + _parent = null + + +func attribute(name :String, value :Variant) -> XmlElement: + _attributes[name] = str(value) + return self + + +func text(p_text :String) -> XmlElement: + _text = p_text if p_text.ends_with("\n") else p_text + "\n" + return self + + +func add_child(child :XmlElement) -> XmlElement: + _childs.append(child) + child._parent = self + return self + + +func add_childs(childs :Array[XmlElement]) -> XmlElement: + for child in childs: + @warning_ignore("return_value_discarded") + add_child(child) + return self + + +func indentation() -> String: + return "" if _parent == null else _parent.indentation() + " " + + +func to_xml() -> String: + var attributes := "" + for key in _attributes.keys() as Array[String]: + attributes += ' {attr}="{value}"'.format({"attr": key, "value": _attributes.get(key)}) + + var childs := "" + for child in _childs: + childs += child.to_xml() + + return "{_indentation}<{name}{attributes}>\n{childs}{text}{_indentation}\n"\ + .format({"name": _name, + "attributes": attributes, + "childs": childs, + "_indentation": indentation(), + "text": cdata(_text)}) + + +func cdata(p_text :String) -> String: + return "" if p_text.is_empty() else "\n".format({"text" : p_text}) diff --git a/addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid b/addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid new file mode 100644 index 0000000..2423624 --- /dev/null +++ b/addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid @@ -0,0 +1 @@ +uid://cljc6joix5w01 diff --git a/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd b/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd new file mode 100644 index 0000000..5d8b631 --- /dev/null +++ b/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd @@ -0,0 +1,154 @@ +class_name GdUnitSpyBuilder +extends GdUnitClassDoubler + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const SPY_TEMPLATE :GDScript = preload("res://addons/gdUnit4/src/spy/GdUnitSpyImpl.gd") +const EXCLUDE_PROPERTIES_TO_COPY = ["script", "type"] + + +static func build(to_spy: Variant, debug_write := false) -> Variant: + if GdObjects.is_singleton(to_spy): + @warning_ignore("unsafe_cast") + push_error("Spy on a Singleton is not allowed! '%s'" % (to_spy as Object).get_class()) + return null + + # if resource path load it before + if GdObjects.is_scene_resource_path(to_spy): + var scene_resource_path :String = to_spy + if not FileAccess.file_exists(scene_resource_path): + push_error("Can't build spy on scene '%s'! The given resource not exists!" % scene_resource_path) + return null + var scene_to_spy: PackedScene = load(scene_resource_path) + return spy_on_scene(scene_to_spy.instantiate() as Node, debug_write) + # spy checked PackedScene + if GdObjects.is_scene(to_spy): + var scene_to_spy: PackedScene = to_spy + return spy_on_scene(scene_to_spy.instantiate() as Node, debug_write) + # spy checked a scene instance + if GdObjects.is_instance_scene(to_spy): + @warning_ignore("unsafe_cast") + return spy_on_scene(to_spy as Node, debug_write) + + var excluded_functions := [] + if to_spy is Callable: + @warning_ignore("unsafe_cast") + to_spy = CallableDoubler.new(to_spy as Callable) + excluded_functions = CallableDoubler.excluded_functions() + + var spy := spy_on_script(to_spy, excluded_functions, debug_write) + if spy == null: + return null + var spy_instance: Object = spy.new() + @warning_ignore("unsafe_method_access") + # we do not call the original implementation for _ready and all input function, this is actualy done by the engine + spy_instance.__init(["_input", "_gui_input", "_input_event", "_unhandled_input"]) + @warning_ignore("unsafe_cast") + copy_properties(to_spy as Object, spy_instance) + @warning_ignore("return_value_discarded") + GdUnitObjectInteractions.reset(spy_instance) + return register_auto_free(spy_instance) + + +static func get_class_info(clazz :Variant) -> Dictionary: + var clazz_path := GdObjects.extract_class_path(clazz) + var clazz_name :String = GdObjects.extract_class_name(clazz).value() + return { + "class_name" : clazz_name, + "class_path" : clazz_path + } + + +static func spy_on_script(instance: Variant, function_excludes: PackedStringArray, debug_write: bool) -> GDScript: + if GdArrayTools.is_array_type(instance): + if GdUnitSettings.is_verbose_assert_errors(): + push_error("Can't build spy checked type '%s'! Spy checked Container Built-In Type not supported!" % type_string(typeof(instance))) + return null + var class_info := get_class_info(instance) + var clazz_name :String = class_info.get("class_name") + var clazz_path :PackedStringArray = class_info.get("class_path", [clazz_name]) + if not GdObjects.is_instance(instance): + if GdUnitSettings.is_verbose_assert_errors(): + push_error("Can't build spy for class type '%s'! Using an instance instead e.g. 'spy()'" % [clazz_name]) + return null + + @warning_ignore("unsafe_method_access") + var spy_template := SPY_TEMPLATE.source_code.format({ + "instance_id" : abs(instance.get_instance_id()), + "gdunit_source_class": clazz_name if clazz_path.is_empty() else clazz_path[0] + }) + @warning_ignore("unsafe_cast") + var lines := load_template(spy_template, class_info) + @warning_ignore("unsafe_cast") + lines += double_functions(instance as Object, clazz_name, clazz_path, GdUnitSpyFunctionDoubler.new(), function_excludes) + # We disable warning/errors for inferred_declaration + if Engine.get_version_info().hex >= 0x40400: + lines.insert(0, '@warning_ignore_start("inferred_declaration")') + lines.append('@warning_ignore_restore("inferred_declaration")') + + var spy := GDScript.new() + spy.source_code = "\n".join(lines) + spy.resource_name = "Spy%s.gd" % clazz_name + spy.resource_path = GdUnitFileAccess.create_temp_dir("spy") + "/Spy%s_%d.gd" % [clazz_name, Time.get_ticks_msec()] + + if debug_write: + @warning_ignore("return_value_discarded") + DirAccess.remove_absolute(spy.resource_path) + @warning_ignore("return_value_discarded") + ResourceSaver.save(spy, spy.resource_path) + var error := spy.reload(true) + if error != OK: + push_error("Unexpected Error!, SpyBuilder error, please contact the developer.") + return null + return spy + + +static func spy_on_scene(scene :Node, debug_write :bool) -> Object: + if scene.get_script() == null: + if GdUnitSettings.is_verbose_assert_errors(): + push_error("Can't create a spy checked a scene without script '%s'" % scene.get_scene_file_path()) + return null + # buils spy checked original script + @warning_ignore("unsafe_cast") + var scene_script :Object = (scene.get_script() as GDScript).new() + var spy := spy_on_script(scene_script, GdUnitClassDoubler.EXLCUDE_SCENE_FUNCTIONS, debug_write) + scene_script.free() + if spy == null: + return null + + # we need to restore the original script properties to apply after script exchange + var original_properties := {} + for p in scene.get_property_list(): + var property_name: String = p["name"] + var usage: int = p["usage"] + if (usage & PROPERTY_USAGE_SCRIPT_VARIABLE) == PROPERTY_USAGE_SCRIPT_VARIABLE: + original_properties[property_name] = scene.get(property_name) + + # exchage with spy + scene.set_script(spy) + # apply original script properties to the spy + for property_name: String in original_properties.keys(): + scene.set(property_name, original_properties[property_name]) + + @warning_ignore("unsafe_method_access") + scene.__init() + return register_auto_free(scene) + + +static func copy_properties(source :Object, dest :Object) -> void: + for property in source.get_property_list(): + var property_name :String = property["name"] + var property_value :Variant = source.get(property_name) + if EXCLUDE_PROPERTIES_TO_COPY.has(property_name): + continue + #if dest.get(property_name) == null: + # prints("|%s|" % property_name, source.get(property_name)) + + # check for invalid name property + if property_name == "name" and property_value == "": + dest.set(property_name, ""); + continue + dest.set(property_name, property_value) + + +static func register_auto_free(obj :Variant) -> Variant: + return GdUnitThreadManager.get_current_context().get_execution_context().register_auto_free(obj) diff --git a/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd.uid b/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd.uid new file mode 100644 index 0000000..c01cdf8 --- /dev/null +++ b/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd.uid @@ -0,0 +1 @@ +uid://b12se7w2ld3no diff --git a/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd new file mode 100644 index 0000000..4cdfdd1 --- /dev/null +++ b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd @@ -0,0 +1,50 @@ +class_name DoubledSpyClassSourceClassName + +const __INSTANCE_ID := "gdunit_doubler_instance_id_{instance_id}" + + +class GdUnitSpyDoublerState: + const __SOURCE_CLASS := "{gdunit_source_class}" + + var excluded_methods := PackedStringArray() + + func _init(excluded_methods__ := PackedStringArray()) -> void: + excluded_methods = excluded_methods__ + + +var __spy_state := GdUnitSpyDoublerState.new() +@warning_ignore("unused_private_class_variable") +var __verifier_instance := GdUnitObjectInteractionsVerifier.new() + + +func __init(__excluded_methods := PackedStringArray()) -> void: + __init_doubler() + __spy_state.excluded_methods = __excluded_methods + + +static func __doubler_state() -> GdUnitSpyDoublerState: + if Engine.has_meta(__INSTANCE_ID): + return Engine.get_meta(__INSTANCE_ID).__spy_state + return null + + +func __init_doubler() -> void: + Engine.set_meta(__INSTANCE_ID, self) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE and Engine.has_meta(__INSTANCE_ID): + Engine.remove_meta(__INSTANCE_ID) + + +static func __get_verifier() -> GdUnitObjectInteractionsVerifier: + # not yet initialized, happens when a base class field initializer calls an overridden + # function before the spy's `_init()` had a chance to run `__init_doubler()` + if not Engine.has_meta(__INSTANCE_ID): + return null + return Engine.get_meta(__INSTANCE_ID).__verifier_instance + + +static func __do_call_real_func(__func_name: String) -> bool: + @warning_ignore("unsafe_method_access") + return not __doubler_state().excluded_methods.has(__func_name) diff --git a/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd.uid b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd.uid new file mode 100644 index 0000000..27b6343 --- /dev/null +++ b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd.uid @@ -0,0 +1 @@ +uid://dkrio66ov1yuj diff --git a/addons/gdUnit4/src/ui/GdUnitConsole.gd b/addons/gdUnit4/src/ui/GdUnitConsole.gd new file mode 100644 index 0000000..0a4c3a4 --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitConsole.gd @@ -0,0 +1,89 @@ +@tool +extends Control + +const GdUnitUpdateClient = preload("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd") +const TITLE = "gdUnit4 ${version} Console" + +@onready var header := $VBoxContainer/Header +@onready var title: RichTextLabel = $VBoxContainer/Header/header_title +@onready var output: RichTextLabel = $VBoxContainer/Console/TextEdit + + +var _test_reporter: GdUnitConsoleTestReporter + + +func _ready() -> void: + GdUnitFonts.init_fonts(output) + output.add_theme_constant_override("line_separation", 3) + GdUnit4Version.init_version_label(title) + GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event) + GdUnitSignals.instance().gdunit_message.connect(_on_gdunit_message) + GdUnitSignals.instance().gdunit_client_connected.connect(_on_gdunit_client_connected) + GdUnitSignals.instance().gdunit_client_disconnected.connect(_on_gdunit_client_disconnected) + _test_reporter = GdUnitConsoleTestReporter.new(GdUnitRichTextMessageWriter.new(output)) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + var instance := GdUnitSignals.instance() + if instance.gdunit_event.is_connected(_on_gdunit_event): + instance.gdunit_event.disconnect(_on_gdunit_event) + if instance.gdunit_message.is_connected(_on_gdunit_event): + instance.gdunit_message.disconnect(_on_gdunit_message) + if instance.gdunit_client_connected.is_connected(_on_gdunit_event): + instance.gdunit_client_connected.disconnect(_on_gdunit_client_connected) + if instance.gdunit_client_disconnected.is_connected(_on_gdunit_event): + instance.gdunit_client_disconnected.disconnect(_on_gdunit_client_disconnected) + + +func setup_update_notification(control: Button) -> void: + if not GdUnitSettings.is_update_notification_enabled(): + _test_reporter.println_message("The search for updates is deactivated.", Color.CORNFLOWER_BLUE) + return + + _test_reporter.print_message("Searching for updates... ", Color.CORNFLOWER_BLUE) + var update_client := GdUnitUpdateClient.new() + add_child(update_client) + var response :GdUnitUpdateClient.HttpResponse = await update_client.request_latest_version() + if response.status() != 200: + _test_reporter.println_message("Information cannot be retrieved from GitHub!", GdUnitEditorColorTheme.state_failure) + _test_reporter.println_message("Error: %s" % response.response(), GdUnitEditorColorTheme.state_failure) + return + var latest_version := update_client.extract_latest_version(response) + if not latest_version.is_greater(GdUnit4Version.current()): + _test_reporter.println_message("GdUnit4 is up-to-date.", GdUnitEditorColorTheme.state_success) + return + + _test_reporter.println_message("A new update is available %s" % latest_version, GdUnitEditorColorTheme.state_warning) + _test_reporter.println_message("Open the GdUnit4 settings and check the update tab.", GdUnitEditorColorTheme.state_warning) + + control.icon = GdUnitUiTools.get_icon("Notification", GdUnitEditorColorTheme.state_warning) + var tween := create_tween() + tween.tween_property(control, "self_modulate", Color.VIOLET, .2).set_trans(Tween.TransitionType.TRANS_LINEAR) + tween.tween_property(control, "self_modulate", GdUnitEditorColorTheme.state_warning, .2).set_trans(Tween.TransitionType.TRANS_BOUNCE) + tween.parallel() + tween.tween_property(control, "scale", Vector2.ONE*1.05, .4).set_trans(Tween.TransitionType.TRANS_LINEAR) + tween.tween_property(control, "scale", Vector2.ONE, .4).set_trans(Tween.TransitionType.TRANS_BOUNCE) + tween.set_loops(-1) + tween.play() + + +func _on_gdunit_event(event: GdUnitEvent) -> void: + match event.type(): + GdUnitEvent.SESSION_START: + _test_reporter.test_session = GdUnitTestSession.new(GdUnitTestDiscoverGuard.instance().get_discovered_tests(), "") + GdUnitEvent.SESSION_CLOSE: + _test_reporter.test_session = null + + +func _on_gdunit_client_connected(client_id: int) -> void: + _test_reporter.clear() + _test_reporter.println_message("GdUnit Test Client connected with id: %d" % client_id, GdUnitEditorColorTheme.folder_color) + + +func _on_gdunit_client_disconnected(client_id: int) -> void: + _test_reporter.println_message("GdUnit Test Client disconnected with id: %d" % client_id, GdUnitEditorColorTheme.folder_color) + + +func _on_gdunit_message(message: String) -> void: + _test_reporter.println_message(message, GdUnitEditorColorTheme.folder_color) diff --git a/addons/gdUnit4/src/ui/GdUnitConsole.gd.uid b/addons/gdUnit4/src/ui/GdUnitConsole.gd.uid new file mode 100644 index 0000000..fcf1775 --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitConsole.gd.uid @@ -0,0 +1 @@ +uid://bn251drwdfko7 diff --git a/addons/gdUnit4/src/ui/GdUnitConsole.tscn b/addons/gdUnit4/src/ui/GdUnitConsole.tscn new file mode 100644 index 0000000..060cc97 --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitConsole.tscn @@ -0,0 +1,70 @@ +[gd_scene format=4 uid="uid://dm0wvfyeew7vd"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/GdUnitConsole.gd" id="1"] + +[node name="Control" type="Control" unique_id=1838139573] +use_parent_material = true +clip_contents = true +custom_minimum_size = Vector2(0, 200) +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1") + +[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=1582573516] +use_parent_material = true +clip_contents = true +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Header" type="Control" parent="VBoxContainer" unique_id=1823073031] +auto_translate_mode = 2 +custom_minimum_size = Vector2(0, 36) +layout_mode = 2 +size_flags_vertical = 0 +localize_numeral_system = false + +[node name="header_title" type="RichTextLabel" parent="VBoxContainer/Header" unique_id=20617532] +auto_translate_mode = 2 +custom_minimum_size = Vector2(0, 36) +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +localize_numeral_system = false +bbcode_enabled = true +scroll_active = false +autowrap_mode = 0 +shortcut_keys_enabled = false +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Console" type="ScrollContainer" parent="VBoxContainer" unique_id=547060555] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="TextEdit" type="RichTextLabel" parent="VBoxContainer/Console" unique_id=1121183605] +use_parent_material = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +focus_mode = 2 +bbcode_enabled = true +scroll_following = true +context_menu_enabled = true +selection_enabled = true diff --git a/addons/gdUnit4/src/ui/GdUnitFonts.gd b/addons/gdUnit4/src/ui/GdUnitFonts.gd new file mode 100644 index 0000000..1dba6a3 --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitFonts.gd @@ -0,0 +1,36 @@ +@tool +class_name GdUnitFonts +extends RefCounted + + +static func init_fonts(item: CanvasItem) -> float: + # set default size + item.set("theme_override_font_sizes/font_size", 16) + + if Engine.is_editor_hint(): + var base_control := EditorInterface.get_base_control() + # source modules/mono/editor/GodotTools/GodotTools/Build/BuildOutputView.cs + # https://github.com/godotengine/godot/blob/9ee1873ae1e09c217ac24a5800007f63cb895615/editor/editor_log.cpp#L65 + var output_source_mono := base_control.get_theme_font("output_source_mono", "EditorFonts") + var output_source_bold_italic := base_control.get_theme_font("output_source_bold_italic", "EditorFonts") + var output_source_italic := base_control.get_theme_font("output_source_italic", "EditorFonts") + var output_source_bold := base_control.get_theme_font("output_source_bold", "EditorFonts") + #var output_source := base_control.get_theme_font("output_source", "EditorFonts") + var settings := EditorInterface.get_editor_settings() + var scale_factor := EditorInterface.get_editor_scale() + var font_size: float = settings.get_setting("interface/editor/main_font_size") + + font_size *= scale_factor + #item.set("theme_override_fonts/normal_font", output_source) + item.set("theme_override_fonts/bold_font", output_source_bold) + item.set("theme_override_fonts/italics_font", output_source_italic) + item.set("theme_override_fonts/bold_italics_font", output_source_bold_italic) + item.set("theme_override_fonts/mono_font", output_source_mono) + item.set("theme_override_font_sizes/font_size", font_size) + item.set("theme_override_font_sizes/normal_font_size", font_size) + item.set("theme_override_font_sizes/bold_font_size", font_size) + item.set("theme_override_font_sizes/italics_font_size", font_size) + item.set("theme_override_font_sizes/bold_italics_font_size", font_size) + item.set("theme_override_font_sizes/mono_font_size", font_size) + return font_size + return 16.0 diff --git a/addons/gdUnit4/src/ui/GdUnitFonts.gd.uid b/addons/gdUnit4/src/ui/GdUnitFonts.gd.uid new file mode 100644 index 0000000..27450db --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitFonts.gd.uid @@ -0,0 +1 @@ +uid://c6j408ijvlfgg diff --git a/addons/gdUnit4/src/ui/GdUnitInspector.gd b/addons/gdUnit4/src/ui/GdUnitInspector.gd new file mode 100644 index 0000000..f5eae3a --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitInspector.gd @@ -0,0 +1,33 @@ +@tool +class_name GdUnitInspecor +extends Control + + +var _command_handler := GdUnitCommandHandler.instance() +var _wait_time := 0.0 + + +func _ready() -> void: + @warning_ignore("return_value_discarded") + GdUnitSignals.instance().gdunit_event.connect(func(event: GdUnitEvent) -> void: + if event.type() != GdUnitEvent.SESSION_START: + return + + var control: Control = get_parent_control() + # if the tab is floating we dont need to set as current + if control is TabContainer: + var tab_container :TabContainer = control + for tab_index in tab_container.get_tab_count(): + if tab_container.get_tab_title(tab_index) == "GdUnit": + tab_container.set_current_tab(tab_index) + ) + + # Register for editor theme updates + add_child(GdUnitEditorColorTheme.new(), true, Node.INTERNAL_MODE_BACK) + + +func _process(delta: float) -> void: + _wait_time += delta + if _wait_time > 2.0: + _wait_time = 0 + _command_handler._do_process() diff --git a/addons/gdUnit4/src/ui/GdUnitInspector.gd.uid b/addons/gdUnit4/src/ui/GdUnitInspector.gd.uid new file mode 100644 index 0000000..42c38e9 --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitInspector.gd.uid @@ -0,0 +1 @@ +uid://d3o6hpe80hpbx diff --git a/addons/gdUnit4/src/ui/GdUnitInspector.tscn b/addons/gdUnit4/src/ui/GdUnitInspector.tscn new file mode 100644 index 0000000..53c483f --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitInspector.tscn @@ -0,0 +1,68 @@ +[gd_scene format=3 uid="uid://mpo5o6d4uybu"] + +[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/GdUnitInspectorToolBar.tscn" id="1"] +[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/GdUnitInspector.gd" id="1_j37cs"] +[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/GdUnitInspectorProgressBar.tscn" id="2"] +[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/GdUnitInspectorStatusBar.tscn" id="3"] +[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.tscn" id="7"] +[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/network/GdUnitServer.tscn" id="7_721no"] + +[node name="GdUnit" type="Control" unique_id=272268338] +clip_contents = true +custom_minimum_size = Vector2(360, 440) +layout_mode = 3 +anchors_preset = 9 +anchor_bottom = 1.0 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +focus_mode = 2 +script = ExtResource("1_j37cs") + +[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=1550259230] +clip_contents = true +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_vertical = 11 +theme_override_constants/separation = 0 + +[node name="Header" type="VBoxContainer" parent="VBoxContainer" unique_id=876343251] +use_parent_material = true +clip_contents = true +layout_mode = 2 + +[node name="ToolBar" parent="VBoxContainer/Header" unique_id=185399834 instance=ExtResource("1")] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="ProgressBar" parent="VBoxContainer/Header" unique_id=360281822 instance=ExtResource("2")] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 5 + +[node name="StatusBar" parent="VBoxContainer/Header" unique_id=625414847 instance=ExtResource("3")] +layout_mode = 2 +size_flags_horizontal = 1 + +[node name="MainPanel" parent="VBoxContainer" unique_id=1133307707 instance=ExtResource("7")] +unique_name_in_owner = true +clip_contents = true +layout_mode = 2 + +[node name="event_server" parent="." unique_id=756999253 instance=ExtResource("7_721no")] + +[connection signal="select_error_next" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_next_item_by_state" binds= [7]] +[connection signal="select_error_prevous" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_previous_item_by_state" binds= [7]] +[connection signal="select_failure_next" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_next_item_by_state" binds= [6]] +[connection signal="select_failure_prevous" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_previous_item_by_state" binds= [6]] +[connection signal="select_flaky_next" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_next_item_by_state" binds= [5]] +[connection signal="select_flaky_prevous" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_previous_item_by_state" binds= [5]] +[connection signal="select_orphan_next" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_next_item_by_state" binds= [9]] +[connection signal="select_orphan_prevous" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_previous_item_by_state" binds= [9]] +[connection signal="select_skipped_next" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_next_item_by_state" binds= [2]] +[connection signal="select_skipped_prevous" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_previous_item_by_state" binds= [2]] +[connection signal="tree_view_mode_changed" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_status_bar_tree_view_mode_changed"] diff --git a/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd new file mode 100644 index 0000000..00f5bc9 --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd @@ -0,0 +1,32 @@ +class_name GdUnitInspectorTreeConstants +extends RefCounted + + +# the inspector panel presantation +enum TREE_VIEW_MODE { + TREE, + FLAT +} + + +# The inspector sort modes +enum SORT_MODE { + UNSORTED, + NAME_ASCENDING, + NAME_DESCENDING, + EXECUTION_TIME +} + + +enum STATE { + INITIAL, + RUNNING, + SKIPPED, + SUCCESS, + WARNING, + FLAKY, + FAILED, + ERROR, + ABORDED, + ORPHAN +} diff --git a/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid new file mode 100644 index 0000000..e433a17 --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid @@ -0,0 +1 @@ +uid://c3gaj34a5hqu5 diff --git a/addons/gdUnit4/src/ui/GdUnitScriptEditorControls.gd b/addons/gdUnit4/src/ui/GdUnitScriptEditorControls.gd new file mode 100644 index 0000000..ef27eef --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitScriptEditorControls.gd @@ -0,0 +1,108 @@ +# A tool to provide extended script editor functionallity +class_name GdUnitScriptEditorControls +extends RefCounted + + +static var _command_ids_initalizied := false +static var FILE_CLOSE := -1 +static var FILE_CLOSE_ALL := -1 +static var FILE_SAVE := -1 +static var FILE_SAVE_ALL := -1 + + +## We scan the file popup menu to find the file command ids to trigger it on save, close, etc. +static func init_file_command_ids() -> void: + if _command_ids_initalizied: + return + + var popup := get_file_menu_popup() + for itemIndex in popup.item_count: + var command := popup.get_item_text(itemIndex) + var command_id := popup.get_item_id(itemIndex) + match command: + "Close": + FILE_CLOSE = command_id + "Close All": + FILE_CLOSE_ALL = command_id + "Save": + FILE_SAVE = command_id + "Save All": + FILE_SAVE_ALL = command_id + + if FILE_CLOSE == -1: + push_error("Can't determine ScriptEditor 'Close' command.") + if FILE_CLOSE_ALL == -1: + push_error("Can't determine ScriptEditor 'Close All' command.") + if FILE_SAVE == -1: + push_error("Can't determine ScriptEditor 'Save' command.") + if FILE_SAVE_ALL == -1: + push_error("Can't determine ScriptEditor 'Save All' command.") + _command_ids_initalizied = true + + +# Saves the given script and closes if requested by +# The script is saved when is opened in the editor. +# The script is closed when is set to true. +static func save_and_close_script(script_path: String, close := false) -> bool: + if !Engine.is_editor_hint(): + return false + + init_file_command_ids() + var editor := EditorInterface.get_script_editor() + # search for the script in all opened editor scrips + for open_script in editor.get_open_scripts(): + if open_script.resource_path == script_path: + # select the script in the editor + EditorInterface.edit_script(open_script, 0); + run_file_command(FILE_SAVE) + if close: + run_file_command(FILE_CLOSE) + return true + return false + + +# Saves all opened script +static func save_all_open_script() -> void: + init_file_command_ids() + run_file_command(FILE_SAVE_ALL) + + +static func close_open_editor_scripts() -> void: + init_file_command_ids() + run_file_command(FILE_CLOSE_ALL) + + +# Edits the given script. +# The script is openend in the current editor and selected in the file system dock. +# The line and column on which to open the script can also be specified. +# The script will be open with the user-configured editor for the script's language which may be an external editor. +static func edit_script(script_path: String, line_number := -1) -> void: + if Engine.is_editor_hint(): + # scan() is required — update_file() alone does not refresh the dock for newly created files + var file_system := EditorInterface.get_resource_filesystem() + file_system.update_file(script_path) + file_system.scan() + var file_system_dock := EditorInterface.get_file_system_dock() + file_system_dock.navigate_to_path(script_path) + EditorInterface.select_file(script_path) + var script: GDScript = load(script_path) + EditorInterface.edit_script(script, line_number) + + +static func get_file_menu_popup() -> PopupMenu: + @warning_ignore("unsafe_method_access") + return EditorInterface.get_script_editor().get_child(0).get_child(0).get_child(0).get_popup() + + +static func run_file_command(command_id: int) -> void: + if Engine.is_editor_hint(): + get_file_menu_popup().id_pressed.emit(command_id) + + +static func _print_menu(popup: PopupMenu) -> void: + for itemIndex in popup.item_count: + prints("get_item_id", popup.get_item_id(itemIndex)) + prints("get_item_accelerator", popup.get_item_accelerator(itemIndex)) + prints("get_item_shortcut", popup.get_item_shortcut(itemIndex)) + prints("get_item_text", popup.get_item_text(itemIndex)) + prints() diff --git a/addons/gdUnit4/src/ui/GdUnitScriptEditorControls.gd.uid b/addons/gdUnit4/src/ui/GdUnitScriptEditorControls.gd.uid new file mode 100644 index 0000000..2f21e9a --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitScriptEditorControls.gd.uid @@ -0,0 +1 @@ +uid://b065hlwt6wa6w diff --git a/addons/gdUnit4/src/ui/GdUnitUiTools.gd b/addons/gdUnit4/src/ui/GdUnitUiTools.gd new file mode 100644 index 0000000..a968d00 --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitUiTools.gd @@ -0,0 +1,171 @@ +@tool +class_name GdUnitUiTools +extends RefCounted + + +const STATE = GdUnitInspectorTreeConstants.STATE + + +enum ImageFlipMode { + HORIZONTAl, + VERITCAL +} + + +static var _spinner: AnimatedTexture +static var icon_cache: Dictionary[STATE, Texture2D] + + +static func get_state_icon(state: STATE) -> Texture2D: + if icon_cache.has(state): + return icon_cache.get(state) + + if not Engine.is_editor_hint(): + return null + + var icon: Texture2D + match state: + STATE.INITIAL: + icon = get_icon("EditorHandleDisabled", GdUnitEditorColorTheme.state_initial) + STATE.RUNNING: + icon = get_spinner() + STATE.SUCCESS: + icon = get_icon("ImportCheck", GdUnitEditorColorTheme.state_success) + STATE.WARNING: + icon = get_icon("ImportCheck", GdUnitEditorColorTheme.state_warning) + STATE.FAILED: + icon = get_icon("ImportFail", GdUnitEditorColorTheme.state_failure) + STATE.ERROR: + icon = get_icon("StatusError", GdUnitEditorColorTheme.state_error) + STATE.FLAKY: + icon = get_icon("CheckBox", GdUnitEditorColorTheme.state_flaky) + STATE.SKIPPED: + icon = get_icon("EditorHandleDisabled", GdUnitEditorColorTheme.state_skipped) + STATE.ORPHAN: + icon = get_icon("Unlinked", GdUnitEditorColorTheme.state_orphan) + + if icon == null: + push_error("missing icon for state:", STATE.keys()[state]) + + icon_cache[state] = icon + return icon + + +## Returns the icon by name, if it exists. +static func get_icon(icon_name: String, color: = Color.BLACK) -> Texture2D: + if not Engine.is_editor_hint(): + return null + var icon := EditorInterface.get_base_control().get_theme_icon(icon_name, "EditorIcons") + if icon == null: + return null + if color != Color.BLACK: + icon = _modulate_texture(icon, color) + return icon + + +## Returns the icon flipped +static func get_flipped_icon(icon_name: String, mode: = ImageFlipMode.HORIZONTAl) -> Texture2D: + if not Engine.is_editor_hint(): + return null + var icon := EditorInterface.get_base_control().get_theme_icon(icon_name, "EditorIcons") + if icon == null: + return null + return ImageTexture.create_from_image(_flip_image(icon, mode)) + + +static func get_spinner() -> AnimatedTexture: + if _spinner != null: + return _spinner + _spinner = AnimatedTexture.new() + _spinner.frames = 8 + _spinner.speed_scale = 2.5 + for frame in _spinner.frames: + _spinner.set_frame_texture(frame, get_icon("Progress%d" % (frame+1))) + _spinner.set_frame_duration(frame, 0.2) + return _spinner + + +static func get_color_animated_icon(icon_name: String, from: Color, to: Color) -> AnimatedTexture: + if not Engine.is_editor_hint(): + return null + var texture := AnimatedTexture.new() + texture.frames = 8 + texture.speed_scale = 2.5 + var color := from + for frame in texture.frames: + color = lerp(color, to, 0.2) + texture.set_frame_texture(frame, get_icon(icon_name, color)) + texture.set_frame_duration(frame, .2) + return texture + + +static func get_run_overall_icon() -> Texture2D: + if not Engine.is_editor_hint(): + return null + var icon := EditorInterface.get_base_control().get_theme_icon("Play", "EditorIcons") + var image := _merge_images(icon.get_image(), Vector2i(-2, 0), icon.get_image(), Vector2i(3, 0)) + return ImageTexture.create_from_image(image) + + +static func _modulate_texture(texture: Texture2D, color: Color) -> Texture2D: + var image := _modulate_image(texture.get_image(), color) + return ImageTexture.create_from_image(image) + + +static func _modulate_image(image: Image, color: Color) -> Image: + var data: PackedByteArray = image.data["data"] + for pixel in range(0, data.size(), 4): + var pixel_a := _to_color(data, pixel) + if pixel_a.a8 != 0: + pixel_a = pixel_a.lerp(color, .9) + data[pixel + 0] = pixel_a.r8 + data[pixel + 1] = pixel_a.g8 + data[pixel + 2] = pixel_a.b8 + # data[pixel + 3] = 1 + var output_image := Image.new() + output_image.set_data(image.get_width(), image.get_height(), image.has_mipmaps(), image.get_format(), data) + return output_image + + +static func _merge_images(image1: Image, offset1: Vector2i, image2: Image, offset2: Vector2i) -> Image: + ## we need to fix the image to have the same size to avoid merge conflicts + if image1.get_height() < image2.get_height(): + image1.resize(image2.get_width(), image2.get_height()) + # Create a new Image for the merged result + var merged_image := Image.create(image1.get_width(), image1.get_height(), false, Image.FORMAT_RGBA8) + merged_image.blit_rect(image1, Rect2(Vector2.ZERO, image1.get_size()), offset1) + merged_image.blit_rect_mask(image2, image2, Rect2(Vector2.ZERO, image2.get_size()), offset2) + return merged_image + + +@warning_ignore("narrowing_conversion") +static func _merge_images_scaled(image1: Image, offset1: Vector2i, image2: Image, offset2: Vector2i) -> Image: + ## we need to fix the image to have the same size to avoid merge conflicts + if image1.get_height() < image2.get_height(): + image1.resize(image2.get_width(), image2.get_height()) + # Create a new Image for the merged result + var merged_image := Image.create(image1.get_width(), image1.get_height(), false, image1.get_format()) + merged_image.blend_rect(image1, Rect2(Vector2.ZERO, image1.get_size()), offset1) + @warning_ignore("narrowing_conversion") + #image2.resize(image2.get_width()/1.3, image2.get_height()/1.3) + merged_image.blit_rect_mask(image2, image2, Rect2(Vector2.ZERO, image2.get_size()), offset2) + return merged_image + + +static func _flip_image(texture: Texture2D, mode: ImageFlipMode) -> Image: + var flipped_image := Image.new() + flipped_image.copy_from(texture.get_image()) + if mode == ImageFlipMode.VERITCAL: + flipped_image.flip_x() + else: + flipped_image.flip_y() + return flipped_image + + +static func _to_color(data: PackedByteArray, position: int) -> Color: + var pixel_a := Color() + pixel_a.r8 = data[position + 0] + pixel_a.g8 = data[position + 1] + pixel_a.b8 = data[position + 2] + pixel_a.a8 = data[position + 3] + return pixel_a diff --git a/addons/gdUnit4/src/ui/GdUnitUiTools.gd.uid b/addons/gdUnit4/src/ui/GdUnitUiTools.gd.uid new file mode 100644 index 0000000..21cb187 --- /dev/null +++ b/addons/gdUnit4/src/ui/GdUnitUiTools.gd.uid @@ -0,0 +1 @@ +uid://b7lv64pwq2wab diff --git a/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd b/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd new file mode 100644 index 0000000..3849e31 --- /dev/null +++ b/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd @@ -0,0 +1,45 @@ +class_name GdUnitContextMenuItem + + +var command_id: String: + set(value): + command_id = value + get: + return command_id + +var name: StringName: + set(value): + name = value + get: + return name + +var visible: Callable: + set(value): + visible = value + get: + return visible + +var icon: Texture2D: + get: + return GdUnitCommandHandler.instance().command_icon(command_id) + + +func _init(p_command_id: String, p_name: StringName, p_is_visible: Callable) -> void: + assert(p_command_id != null and not p_command_id.is_empty(), "(%s) missing command id " % p_command_id) + assert(p_is_visible != null, "(%s) missing parameter 'GdUnitCommand'" % p_name) + + self.command_id = p_command_id + self.name = p_name + self.visible = p_is_visible + + +func shortcut() -> Shortcut: + return GdUnitCommandHandler.instance().command_shortcut(command_id) + + +func is_visible(...args: Array) -> bool: + return visible.callv(args) + + +func execute(...args: Array) -> void: + GdUnitCommandHandler.instance().command_execute(command_id, args) diff --git a/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd.uid b/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd.uid new file mode 100644 index 0000000..22c9632 --- /dev/null +++ b/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd.uid @@ -0,0 +1 @@ +uid://buds1hj2v8ho8 diff --git a/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd b/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd new file mode 100644 index 0000000..61d630c --- /dev/null +++ b/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd @@ -0,0 +1,45 @@ +@tool +extends EditorContextMenuPlugin + +var _context_menus: Array[GdUnitContextMenuItem] = [] + + +func _init() -> void: + var is_test_suite := func is_visible(script: Script, is_ts: bool) -> bool: + if script == null: + return false + return GdUnitTestSuiteScanner.is_test_suite(script) == is_ts + # setup shortcuts + _context_menus.append(GdUnitContextMenuItem.new( + GdUnitCommandFileSystemRunTests.ID, + "Run Testsuites", + is_test_suite.bind(true) + )) + _context_menus.append(GdUnitContextMenuItem.new( + GdUnitCommandFileSystemDebugTests.ID, + "Debug Testsuites", + is_test_suite.bind(true) + )) + + +func _popup_menu(paths: PackedStringArray) -> void: + # Filter for directories and test suite files + var valid_paths := Array(paths)\ + .filter(func(path: String) -> bool: + if DirAccess.dir_exists_absolute(path): + return true + if path.get_extension() in ["gd", "cs"]: + var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(path) + return GdUnitTestSuiteScanner.is_test_suite(script) + return false) + + # If no valid paths selected don't extend the context menu + if valid_paths.is_empty(): + return + + for menu_item in _context_menus: + if menu_item.shortcut(): + add_menu_shortcut(menu_item.shortcut(), menu_item.execute.bindv(valid_paths).unbind(1)) + add_context_menu_item_from_shortcut(menu_item.name, menu_item.shortcut(), menu_item.icon) + else: + add_context_menu_item(menu_item.name, menu_item.execute.bindv(valid_paths).unbind(1), menu_item.icon) diff --git a/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd.uid b/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd.uid new file mode 100644 index 0000000..0feffdc --- /dev/null +++ b/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd.uid @@ -0,0 +1 @@ +uid://bssaao2gxa6vq diff --git a/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd new file mode 100644 index 0000000..2d6071a --- /dev/null +++ b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd @@ -0,0 +1,63 @@ +@tool +class_name GdUnitInspectorContextMenu +extends PopupMenu + + +const CONTEXT_MENU_RUN_ID = 0 +const CONTEXT_MENU_DEBUG_ID = 1 +const CONTEXT_MENU_RERUN_UNTIL_ID = 2 +# id 3 is the seperator +const CONTEXT_MENU_COLLAPSE_ALL = 4 +const CONTEXT_MENU_EXPAND_ALL = 5 + + +var command_handler: GdUnitCommandHandler + + +func _ready() -> void: + if not Engine.is_editor_hint(): + return + command_handler = GdUnitCommandHandler.instance() + _setup_item(CONTEXT_MENU_RUN_ID, "Run Tests", GdUnitCommandInspectorRunTests.ID) + _setup_item(CONTEXT_MENU_DEBUG_ID, "Debug Tests", GdUnitCommandInspectorDebugTests.ID) + _setup_item(CONTEXT_MENU_RERUN_UNTIL_ID, "Run Tests Until Fail", GdUnitCommandInspectorRerunTestsUntilFailure.ID) + _setup_item(CONTEXT_MENU_EXPAND_ALL, "Expand All", GdUnitCommandInspectorTreeExpand.ID) + _setup_item(CONTEXT_MENU_COLLAPSE_ALL, "Collapse All", GdUnitCommandInspectorTreeCollapse.ID) + + +func _setup_item(item_id: int, item_name: String, command_id: String) -> void: + set_item_text(item_id, item_name) + set_item_icon(item_id, command_handler.command_icon(command_id)) + set_item_shortcut(item_id, command_handler.command_shortcut(command_id)) + + +func disable_items() -> void: + set_item_disabled(CONTEXT_MENU_RUN_ID, true) + set_item_disabled(CONTEXT_MENU_DEBUG_ID, true) + set_item_disabled(CONTEXT_MENU_RERUN_UNTIL_ID, true) + + +func enable_items() -> void: + set_item_disabled(CONTEXT_MENU_RUN_ID, false) + set_item_disabled(CONTEXT_MENU_DEBUG_ID, false) + set_item_disabled(CONTEXT_MENU_RERUN_UNTIL_ID, false) + + +func _on_tree_item_mouse_selected(mouse_position: Vector2, mouse_button_index: int, source: Tree) -> void: + if mouse_button_index == MOUSE_BUTTON_RIGHT: + position = source.get_screen_position() + mouse_position + popup() + + +func _on_index_pressed(index: int) -> void: + match index: + CONTEXT_MENU_RUN_ID: + command_handler.command_execute(GdUnitCommandInspectorRunTests.ID) + CONTEXT_MENU_DEBUG_ID: + command_handler.command_execute(GdUnitCommandInspectorDebugTests.ID) + CONTEXT_MENU_RERUN_UNTIL_ID: + command_handler.command_execute(GdUnitCommandInspectorRerunTestsUntilFailure.ID) + CONTEXT_MENU_EXPAND_ALL: + command_handler.command_execute(GdUnitCommandInspectorTreeExpand.ID) + CONTEXT_MENU_COLLAPSE_ALL: + command_handler.command_execute(GdUnitCommandInspectorTreeCollapse.ID) diff --git a/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd.uid b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd.uid new file mode 100644 index 0000000..58d2ad8 --- /dev/null +++ b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd.uid @@ -0,0 +1 @@ +uid://b8vsj8d7fh4kw diff --git a/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.tscn b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.tscn new file mode 100644 index 0000000..5d87a41 --- /dev/null +++ b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.tscn @@ -0,0 +1,70 @@ +[gd_scene format=3 uid="uid://bjcc71p2h1trr"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd" id="1_0x0vc"] + +[sub_resource type="DPITexture" id="DPITexture_miuuy"] +_source = " +" +saturation = 2.0 +color_map = { +Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1), +Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1), +Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1) +} + +[sub_resource type="DPITexture" id="DPITexture_ern2r"] +_source = " +" +saturation = 2.0 +color_map = { +Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1), +Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1), +Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1) +} + +[sub_resource type="DPITexture" id="DPITexture_qdci2"] +_source = " +" +saturation = 2.0 +color_map = { +Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1), +Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1), +Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1) +} + +[sub_resource type="DPITexture" id="DPITexture_hed0i"] +_source = " +" +saturation = 2.0 +color_map = { +Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1), +Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1), +Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1) +} + +[node name="contextMenu" type="PopupMenu" unique_id=1824556050] +auto_translate_mode = 2 +oversampling_override = 1.0 +size = Vector2i(241, 170) +visible = true +item_count = 6 +item_0/text = "Run Tests" +item_0/icon = SubResource("DPITexture_miuuy") +item_0/id = 0 +item_1/text = "Debug Tests" +item_1/icon = SubResource("DPITexture_ern2r") +item_1/id = 1 +item_2/text = "Run Tests Until Fail" +item_2/icon = SubResource("DPITexture_miuuy") +item_2/id = 2 +item_3/id = 3 +item_3/separator = true +item_4/text = "Collapse All" +item_4/icon = SubResource("DPITexture_qdci2") +item_4/id = 4 +item_5/text = "Expand All" +item_5/icon = SubResource("DPITexture_hed0i") +item_5/id = 5 +script = ExtResource("1_0x0vc") + +[connection signal="index_pressed" from="." to="." method="_on_index_pressed"] diff --git a/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd b/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd new file mode 100644 index 0000000..dc16e12 --- /dev/null +++ b/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd @@ -0,0 +1,38 @@ +@tool +extends EditorContextMenuPlugin + + +var _context_menus: Array[GdUnitContextMenuItem] = [] + + +func _init() -> void: + var is_test_suite := func is_visible(script: Script, is_ts: bool) -> bool: + return GdUnitTestSuiteScanner.is_test_suite(script) == is_ts + # setup shortcuts + _context_menus.append(GdUnitContextMenuItem.new( + GdUnitCommandScriptEditorRunTests.ID, + "Run Tests", + is_test_suite.bind(true) + )) + _context_menus.append(GdUnitContextMenuItem.new( + GdUnitCommandScriptEditorDebugTests.ID, + "Debug Tests", + is_test_suite.bind(true) + )) + _context_menus.append(GdUnitContextMenuItem.new( + GdUnitCommandScriptEditorCreateTest.ID, + "Create Test", + is_test_suite.bind(false) + )) + + +func _popup_menu(_paths: PackedStringArray) -> void: + var current_script := EditorInterface.get_script_editor().get_current_script() + + for menu_item in _context_menus: + if menu_item.is_visible(current_script): + if menu_item.shortcut(): + add_menu_shortcut(menu_item.shortcut(), menu_item.execute.unbind(1)) + add_context_menu_item_from_shortcut(menu_item.name, menu_item.shortcut()) + else: + add_context_menu_item(menu_item.name, menu_item.execute.unbind(1)) diff --git a/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd.uid b/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd.uid new file mode 100644 index 0000000..0b6d464 --- /dev/null +++ b/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd.uid @@ -0,0 +1 @@ +uid://m83gistw0jpv diff --git a/addons/gdUnit4/src/ui/parts/GdUnitInspectorProgressBar.gd b/addons/gdUnit4/src/ui/parts/GdUnitInspectorProgressBar.gd new file mode 100644 index 0000000..d1dcc09 --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitInspectorProgressBar.gd @@ -0,0 +1,56 @@ +@tool +extends ProgressBar + + +@onready var progress_counter: Label = %progess_counter +@onready var style: StyleBoxFlat = get("theme_override_styles/fill") + +var _state: GdUnitInspectorTreeConstants.STATE + + +func _ready() -> void: + style.bg_color = Color.TRANSPARENT + value = 0 + max_value = 0 + update_text() + # register for progress changes + if Engine.is_editor_hint(): + @warning_ignore("unsafe_property_access", "unsafe_method_access") + if get_parent().get_parent().get_parent().find_child("MainPanel", true, false).test_counters_changed.connect(_on_test_counter_changed) != OK: + push_error("ProgressBar: Can't connect to MainPanel") + + +func update_text() -> void: + progress_counter.text = "%d:%d" % [value, max_value] + + +func _on_test_counter_changed(index: int, total: int, state: GdUnitInspectorTreeConstants.STATE) -> void: + value = index + max_value = total + update_text() + + # inital state + if index == 0: + _state = GdUnitInspectorTreeConstants.STATE.INITIAL + style.bg_color = Color.TRANSPARENT + + # do only update the state is higher prio than current state + if state <= _state: + return + _state = state + + if is_failed(state): + style.bg_color = Color.DARK_RED + else: + style.bg_color = Color.DARK_GREEN + + +func is_failed(state: GdUnitInspectorTreeConstants.STATE) -> bool: + return state in [ + GdUnitInspectorTreeConstants.STATE.FAILED, + GdUnitInspectorTreeConstants.STATE.ERROR, + GdUnitInspectorTreeConstants.STATE.ABORDED] + + +func is_flaky(state: GdUnitInspectorTreeConstants.STATE) -> bool: + return state == GdUnitInspectorTreeConstants.STATE.FLAKY diff --git a/addons/gdUnit4/src/ui/parts/GdUnitInspectorProgressBar.gd.uid b/addons/gdUnit4/src/ui/parts/GdUnitInspectorProgressBar.gd.uid new file mode 100644 index 0000000..768bcc9 --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitInspectorProgressBar.gd.uid @@ -0,0 +1 @@ +uid://5jlhfo21m5f4 diff --git a/addons/gdUnit4/src/ui/parts/GdUnitInspectorProgressBar.tscn b/addons/gdUnit4/src/ui/parts/GdUnitInspectorProgressBar.tscn new file mode 100644 index 0000000..851a5b6 --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitInspectorProgressBar.tscn @@ -0,0 +1,49 @@ +[gd_scene format=3 uid="uid://dva3tonxsxrlk"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/GdUnitInspectorProgressBar.gd" id="1"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ayfir"] +bg_color = Color(1, 1, 1, 0) + +[node name="MarginContainer" type="MarginContainer" unique_id=542070056] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 4 +theme_override_constants/margin_top = 2 +theme_override_constants/margin_right = 4 +theme_override_constants/margin_bottom = 2 + +[node name="ProgressBar" type="ProgressBar" parent="." unique_id=419200362] +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 9 +theme_override_styles/fill = SubResource("StyleBoxFlat_ayfir") +allow_greater = true +show_percentage = false +script = ExtResource("1") + +[node name="progess_counter" type="Label" parent="ProgressBar" unique_id=648033469] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -11.5 +offset_top = -11.5 +offset_right = 11.5 +offset_bottom = 11.5 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 6 +size_flags_vertical = 6 +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_trim_flags = 0 +justification_flags = 0 +max_lines_visible = 1 diff --git a/addons/gdUnit4/src/ui/parts/GdUnitInspectorStatusBar.gd b/addons/gdUnit4/src/ui/parts/GdUnitInspectorStatusBar.gd new file mode 100644 index 0000000..89406b5 --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitInspectorStatusBar.gd @@ -0,0 +1,303 @@ +@tool +extends Control + +signal select_failure_next() +signal select_failure_prevous() +signal select_error_next() +signal select_error_prevous() +signal select_flaky_next() +signal select_flaky_prevous() +signal select_skipped_next() +signal select_skipped_prevous() +signal select_orphan_next() +signal select_orphan_prevous() + +@warning_ignore("unused_signal") +signal tree_view_mode_changed(flat :bool) + + +@onready var _button_sync: Button = %btn_tree_sync +@onready var _button_view_mode: MenuButton = %btn_tree_mode +@onready var _button_sort_mode: MenuButton = %btn_tree_sort +@onready var _icon_template: SpinBox = %icon_template +@onready var time_value: Label = %time_value +@onready var time_icon: TextureRect = %time_icon + +const STATE = GdUnitInspectorTreeConstants.STATE + +var total_failed := 0 +var total_errors := 0 +var total_flaky := 0 +var total_skipped := 0 +var total_orphans := 0 +var _timer: LocalTime + + +var icon_mappings := { + # tree sort modes + 0x100 + GdUnitInspectorTreeConstants.SORT_MODE.UNSORTED : GdUnitUiTools.get_icon("TripleBar"), + 0x100 + GdUnitInspectorTreeConstants.SORT_MODE.NAME_ASCENDING : GdUnitUiTools.get_icon("Sort"), + 0x100 + GdUnitInspectorTreeConstants.SORT_MODE.NAME_DESCENDING : GdUnitUiTools.get_flipped_icon("Sort"), + 0x100 + GdUnitInspectorTreeConstants.SORT_MODE.EXECUTION_TIME : GdUnitUiTools.get_icon("History"), + # tree view modes + 0x200 + GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE : GdUnitUiTools.get_icon("Tree", Color.GHOST_WHITE), + 0x200 + GdUnitInspectorTreeConstants.TREE_VIEW_MODE.FLAT : GdUnitUiTools.get_icon("AnimationTrackGroup", Color.GHOST_WHITE) +} + + +@warning_ignore("return_value_discarded") +func _ready() -> void: + _button_sync.icon = GdUnitUiTools.get_icon("Loop") + _set_sort_mode_menu_options() + _set_view_mode_menu_options() + _init_status_pannels() + time_value.text = "0ms" + time_icon.texture = GdUnitUiTools.get_icon("Time") + + GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event) + GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed) + + +func _process(_delta: float) -> void: + if _timer: + time_value.text = _timer.elapsed_since() + + +func _init_status_pannels() -> void: + _init_statistic_panels(STATE.FAILED) + _init_statistic_panels(STATE.ERROR) + _init_statistic_panels(STATE.FLAKY) + _init_statistic_panels(STATE.SKIPPED) + _init_statistic_panels(STATE.ORPHAN) + + +func _notification(what: int) -> void: + if what == EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED: + _init_status_pannels() + + +func _init_statistic_panels(state: STATE) -> void: + var panel: Control = find_child(str(STATE.keys()[state]), true, false) + var icon: TextureRect = panel.find_child("icon") + if icon.texture == null: + icon.texture = GdUnitUiTools.get_state_icon(state) + icon.tooltip_text = panel.get_tooltip_text() + var value: LineEdit = panel.find_child("value") + value.text = "0" + value.tooltip_text = panel.get_tooltip_text() + var btn_up: TextureButton = panel.find_child("up") + var btn_down: TextureButton = panel.find_child("down") + var up_icon := _icon_template.get_theme_icon("up_hover") + var texture_normal := ImageTexture.create_from_image( + GdUnitUiTools._modulate_image(up_icon.get_image(), + Color.GRAY) + ) + var texture_hover := ImageTexture.create_from_image( + GdUnitUiTools._modulate_image(up_icon.get_image(), + Color.WHITE) + ) + var texture_pressed := ImageTexture.create_from_image( + GdUnitUiTools._modulate_image(up_icon.get_image(), + Color.SKY_BLUE) + ) + + btn_up.texture_normal = texture_normal + btn_up.texture_hover = texture_hover + btn_up.texture_pressed = texture_pressed + btn_down.texture_normal = texture_normal + btn_down.texture_hover = texture_hover + btn_down.texture_pressed = texture_pressed + if not btn_up.pressed.is_connected(_on_button_up): + btn_up.pressed.connect(_on_button_up.bind(state)) + if not btn_down.pressed.is_connected(_on_button_down): + btn_down.pressed.connect(_on_button_down.bind(state)) + + +func _on_button_up(state: STATE) -> void: + match state: + STATE.FAILED: + select_failure_prevous.emit() + STATE.ERROR: + select_error_prevous.emit() + STATE.FLAKY: + select_flaky_prevous.emit() + STATE.SKIPPED: + select_skipped_prevous.emit() + STATE.ORPHAN: + select_orphan_prevous.emit() + + +func _on_button_down(state: STATE) -> void: + match state: + STATE.FAILED: + select_failure_next.emit() + STATE.ERROR: + select_error_next.emit() + STATE.FLAKY: + select_flaky_next.emit() + STATE.SKIPPED: + select_skipped_next.emit() + STATE.ORPHAN: + select_orphan_next.emit() + + +func _update_statistics(state: STATE, count: int) -> void: + var panel: Node = find_child(str(STATE.keys()[state]), true, false) + var value: LineEdit = panel.find_child("value") + value.text = str(count) + + +func _set_sort_mode_menu_options() -> void: + _button_sort_mode.icon = GdUnitUiTools.get_icon("Sort") + # construct context sort menu according to the available modes + var context_menu :PopupMenu = _button_sort_mode.get_popup() + context_menu.clear() + + if not context_menu.index_pressed.is_connected(_on_sort_mode_changed): + @warning_ignore("return_value_discarded") + context_menu.index_pressed.connect(_on_sort_mode_changed) + + var configured_sort_mode := GdUnitSettings.get_inspector_tree_sort_mode() + for sort_mode: String in GdUnitInspectorTreeConstants.SORT_MODE.keys(): + var enum_value :int = GdUnitInspectorTreeConstants.SORT_MODE.get(sort_mode) + var icon :Texture2D = icon_mappings[0x100 + enum_value] + context_menu.add_icon_check_item(icon, normalise(sort_mode), enum_value) + context_menu.set_item_checked(enum_value, configured_sort_mode == enum_value) + + +func _set_view_mode_menu_options() -> void: + _button_view_mode.icon = GdUnitUiTools.get_icon("Tree", Color.GHOST_WHITE) + # construct context tree view menu according to the available modes + var context_menu :PopupMenu = _button_view_mode.get_popup() + context_menu.clear() + + if not context_menu.index_pressed.is_connected(_on_tree_view_mode_changed): + @warning_ignore("return_value_discarded") + context_menu.index_pressed.connect(_on_tree_view_mode_changed) + + var configured_tree_view_mode := GdUnitSettings.get_inspector_tree_view_mode() + for tree_view_mode: String in GdUnitInspectorTreeConstants.TREE_VIEW_MODE.keys(): + var enum_value :int = GdUnitInspectorTreeConstants.TREE_VIEW_MODE.get(tree_view_mode) + var icon :Texture2D = icon_mappings[0x200 + enum_value] + context_menu.add_icon_check_item(icon, normalise(tree_view_mode), enum_value) + context_menu.set_item_checked(enum_value, configured_tree_view_mode == enum_value) + + +func normalise(value: String) -> String: + var parts := value.to_lower().split("_") + parts[0] = parts[0].capitalize() + return " ".join(parts) + + +func status_changed(errors: int, failed: int, flaky: int, skipped: int, orphans: int) -> void: + total_failed += failed + total_errors += errors + total_flaky += flaky + total_skipped += skipped + total_orphans += orphans + _update_statistics(STATE.FAILED, total_failed) + _update_statistics(STATE.ERROR, total_errors) + _update_statistics(STATE.FLAKY, total_flaky) + _update_statistics(STATE.SKIPPED, total_skipped) + _update_statistics(STATE.ORPHAN, total_orphans) + + +func disable_buttons(value :bool) -> void: + _button_sync.set_disabled(value) + _button_sort_mode.set_disabled(value) + _button_view_mode.set_disabled(value) + + +func _on_gdunit_event(event: GdUnitEvent) -> void: + match event.type(): + GdUnitEvent.DISCOVER_START: + disable_buttons(true) + + GdUnitEvent.DISCOVER_END: + disable_buttons(false) + + GdUnitEvent.INIT: + total_errors = 0 + total_failed = 0 + total_flaky = 0 + total_skipped = 0 + total_orphans = 0 + status_changed(total_errors, total_failed, total_flaky, total_skipped, total_orphans) + + GdUnitEvent.TESTCASE_AFTER: + status_changed(event.error_count(), event.failed_count(), event.is_flaky(), event.is_skipped(), event.orphan_nodes()) + + GdUnitEvent.TESTSUITE_AFTER: + status_changed(event.error_count(), event.failed_count(), event.is_flaky(), 0, event.orphan_nodes()) + + GdUnitEvent.SESSION_START: + disable_buttons(true) + _timer = LocalTime.now() + + GdUnitEvent.SESSION_CLOSE, GdUnitEvent.STOP: + disable_buttons(false) + _timer = null + + +func _on_btn_error_up_pressed() -> void: + select_error_prevous.emit() + + +func _on_btn_error_down_pressed() -> void: + select_error_next.emit() + + +func _on_failure_up_pressed() -> void: + select_failure_prevous.emit() + + +func _on_failure_down_pressed() -> void: + select_failure_next.emit() + + +func _on_btn_flaky_up_pressed() -> void: + select_flaky_prevous.emit() + + +func _on_btn_flaky_down_pressed() -> void: + select_flaky_next.emit() + + +func _on_btn_skipped_up_pressed() -> void: + select_skipped_prevous.emit() + + +func _on_btn_skipped_down_pressed() -> void: + select_skipped_next.emit() + + +func _on_btn_orphan_up_pressed() -> void: + select_orphan_prevous.emit() + + +func _on_btn_orphan_down_pressed() -> void: + select_orphan_next.emit() + + +func _on_btn_tree_sync_pressed() -> void: + await GdUnitTestDiscoverer.run() + + +func _on_sort_mode_changed(index: int) -> void: + var selected_sort_mode :GdUnitInspectorTreeConstants.SORT_MODE = GdUnitInspectorTreeConstants.SORT_MODE.values()[index] + GdUnitSettings.set_inspector_tree_sort_mode(selected_sort_mode) + + +func _on_tree_view_mode_changed(index: int) ->void: + var selected_tree_mode :GdUnitInspectorTreeConstants.TREE_VIEW_MODE = GdUnitInspectorTreeConstants.TREE_VIEW_MODE.values()[index] + GdUnitSettings.set_inspector_tree_view_mode(selected_tree_mode) + + +################################################################################ +# external signal receiver +################################################################################ +func _on_settings_changed(property :GdUnitProperty) -> void: + if property.name() == GdUnitSettings.INSPECTOR_TREE_SORT_MODE: + _set_sort_mode_menu_options() + if property.name() == GdUnitSettings.INSPECTOR_TREE_VIEW_MODE: + _set_view_mode_menu_options() diff --git a/addons/gdUnit4/src/ui/parts/GdUnitInspectorStatusBar.gd.uid b/addons/gdUnit4/src/ui/parts/GdUnitInspectorStatusBar.gd.uid new file mode 100644 index 0000000..fb1c74f --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitInspectorStatusBar.gd.uid @@ -0,0 +1 @@ +uid://cbrfyg1fv3eb6 diff --git a/addons/gdUnit4/src/ui/parts/GdUnitInspectorStatusBar.tscn b/addons/gdUnit4/src/ui/parts/GdUnitInspectorStatusBar.tscn new file mode 100644 index 0000000..7d6f89a --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitInspectorStatusBar.tscn @@ -0,0 +1,362 @@ +[gd_scene format=3 uid="uid://c22l4odk7qesc"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/GdUnitInspectorStatusBar.gd" id="3"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_mb3ih"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wo03e"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_jh28t"] + +[node name="StatusBar" type="VBoxContainer" unique_id=214621769] +anchors_preset = 10 +anchor_right = 1.0 +offset_bottom = 132.0 +grow_horizontal = 2 +size_flags_horizontal = 3 +size_flags_vertical = 2 +script = ExtResource("3") + +[node name="tree_tools" type="HBoxContainer" parent="." unique_id=1524945370] +layout_mode = 2 +size_flags_vertical = 0 + +[node name="Label" type="Label" parent="tree_tools" unique_id=226892195] +layout_mode = 2 +size_flags_horizontal = 0 +text = "Statistics:" +autowrap_trim_flags = 0 +justification_flags = 0 + +[node name="timings" type="HBoxContainer" parent="tree_tools" unique_id=1494080859] +layout_mode = 2 +size_flags_horizontal = 2 +size_flags_vertical = 4 +alignment = 1 + +[node name="time_icon" type="TextureRect" parent="tree_tools/timings" unique_id=156277722] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 4 +stretch_mode = 2 + +[node name="time_value" type="Label" parent="tree_tools/timings" unique_id=387690970] +unique_name_in_owner = true +custom_minimum_size = Vector2(64, 0) +layout_mode = 2 +size_flags_horizontal = 3 +text = "0ms" +horizontal_alignment = 2 +vertical_alignment = 1 +autowrap_trim_flags = 0 +justification_flags = 0 + +[node name="tree_buttons" type="HBoxContainer" parent="tree_tools" unique_id=1600307316] +layout_mode = 2 +size_flags_horizontal = 10 +size_flags_vertical = 4 +alignment = 2 + +[node name="VSeparator" type="VSeparator" parent="tree_tools/tree_buttons" unique_id=1191926359] +layout_mode = 2 + +[node name="btn_tree_sync" type="Button" parent="tree_tools/tree_buttons" unique_id=2138788372] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "Run discover tests." +theme_override_styles/normal = SubResource("StyleBoxEmpty_mb3ih") + +[node name="btn_tree_sort" type="MenuButton" parent="tree_tools/tree_buttons" unique_id=1452127838] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "Sets tree sorting mode." +theme_override_styles/normal = SubResource("StyleBoxEmpty_wo03e") +flat = false + +[node name="btn_tree_mode" type="MenuButton" parent="tree_tools/tree_buttons" unique_id=1744382439] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "Sets tree presentation mode." +theme_override_styles/normal = SubResource("StyleBoxEmpty_jh28t") +flat = false + +[node name="HSeparator" type="HSeparator" parent="." unique_id=1416084348] +layout_mode = 2 +size_flags_vertical = 4 +theme_override_constants/separation = 0 + +[node name="MarginContainer" type="MarginContainer" parent="." unique_id=89354949] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/margin_left = 4 +theme_override_constants/margin_top = 4 +theme_override_constants/margin_right = 4 +theme_override_constants/margin_bottom = 4 + +[node name="status_bar" type="HFlowContainer" parent="MarginContainer" unique_id=1576624226] +layout_direction = 2 +layout_mode = 2 +size_flags_vertical = 2 + +[node name="icon_template" type="SpinBox" parent="MarginContainer/status_bar" unique_id=1487498266] +unique_name_in_owner = true +visible = false +layout_mode = 2 + +[node name="ERROR" type="HBoxContainer" parent="MarginContainer/status_bar" unique_id=310880909] +layout_mode = 2 +tooltip_text = "Detected Errors" + +[node name="value" type="LineEdit" parent="MarginContainer/status_bar/ERROR" unique_id=1630464164] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 4 +tooltip_text = "Detected Errors" +text = "0" +alignment = 2 +max_length = 3 +editable = false +context_menu_enabled = false +emoji_menu_enabled = false +shortcut_keys_enabled = false +middle_mouse_paste_enabled = false +selecting_enabled = false +deselect_on_focus_loss_enabled = false +drag_and_drop_selection_enabled = false +virtual_keyboard_enabled = false + +[node name="icon" type="TextureRect" parent="MarginContainer/status_bar/ERROR/value" unique_id=729348405] +layout_mode = 1 +anchors_preset = 4 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 4.0 +offset_top = -8.0 +offset_right = 20.0 +offset_bottom = 8.0 +grow_vertical = 2 +size_flags_horizontal = 4 +size_flags_vertical = 4 +stretch_mode = 2 + +[node name="buttons" type="VBoxContainer" parent="MarginContainer/status_bar/ERROR" unique_id=541942806] +layout_mode = 2 +size_flags_horizontal = 0 + +[node name="up" type="TextureButton" parent="MarginContainer/status_bar/ERROR/buttons" unique_id=1146002542] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 10 + +[node name="down" type="TextureButton" parent="MarginContainer/status_bar/ERROR/buttons" unique_id=1867410350] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 2 +flip_v = true + +[node name="FAILED" type="HBoxContainer" parent="MarginContainer/status_bar" unique_id=1390016421] +layout_mode = 2 +tooltip_text = "Failed Tests" + +[node name="value" type="LineEdit" parent="MarginContainer/status_bar/FAILED" unique_id=75554621] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 4 +tooltip_text = "Failed Tests" +text = "0" +alignment = 2 +max_length = 3 +editable = false +context_menu_enabled = false +emoji_menu_enabled = false +shortcut_keys_enabled = false +middle_mouse_paste_enabled = false +selecting_enabled = false +deselect_on_focus_loss_enabled = false +drag_and_drop_selection_enabled = false +virtual_keyboard_enabled = false + +[node name="icon" type="TextureRect" parent="MarginContainer/status_bar/FAILED/value" unique_id=1889621378] +layout_mode = 1 +anchors_preset = 4 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 4.0 +offset_top = -8.0 +offset_right = 20.0 +offset_bottom = 8.0 +grow_vertical = 2 +size_flags_horizontal = 4 +size_flags_vertical = 4 +stretch_mode = 2 + +[node name="buttons" type="VBoxContainer" parent="MarginContainer/status_bar/FAILED" unique_id=699611313] +layout_mode = 2 +size_flags_horizontal = 0 + +[node name="up" type="TextureButton" parent="MarginContainer/status_bar/FAILED/buttons" unique_id=1774403865] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 10 + +[node name="down" type="TextureButton" parent="MarginContainer/status_bar/FAILED/buttons" unique_id=1605438045] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 2 +flip_v = true + +[node name="FLAKY" type="HBoxContainer" parent="MarginContainer/status_bar" unique_id=480870974] +layout_mode = 2 +tooltip_text = "Flaky Tests" + +[node name="value" type="LineEdit" parent="MarginContainer/status_bar/FLAKY" unique_id=2059394293] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 4 +tooltip_text = "Flaky Tests" +text = "0" +alignment = 2 +max_length = 3 +editable = false +context_menu_enabled = false +emoji_menu_enabled = false +shortcut_keys_enabled = false +middle_mouse_paste_enabled = false +selecting_enabled = false +deselect_on_focus_loss_enabled = false +drag_and_drop_selection_enabled = false +virtual_keyboard_enabled = false + +[node name="icon" type="TextureRect" parent="MarginContainer/status_bar/FLAKY/value" unique_id=885817973] +layout_mode = 1 +anchors_preset = 4 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 4.0 +offset_top = -8.0 +offset_right = 20.0 +offset_bottom = 8.0 +grow_vertical = 2 +size_flags_horizontal = 4 +size_flags_vertical = 4 +stretch_mode = 2 + +[node name="buttons" type="VBoxContainer" parent="MarginContainer/status_bar/FLAKY" unique_id=968503310] +layout_mode = 2 +size_flags_horizontal = 0 + +[node name="up" type="TextureButton" parent="MarginContainer/status_bar/FLAKY/buttons" unique_id=1127861257] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 10 + +[node name="down" type="TextureButton" parent="MarginContainer/status_bar/FLAKY/buttons" unique_id=949128796] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 2 +flip_v = true + +[node name="SKIPPED" type="HBoxContainer" parent="MarginContainer/status_bar" unique_id=2053554589] +layout_mode = 2 +tooltip_text = "Skipped Tests" + +[node name="value" type="LineEdit" parent="MarginContainer/status_bar/SKIPPED" unique_id=1877800941] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 4 +tooltip_text = "Skipped Tests" +text = "0" +alignment = 2 +max_length = 3 +editable = false +context_menu_enabled = false +emoji_menu_enabled = false +shortcut_keys_enabled = false +middle_mouse_paste_enabled = false +selecting_enabled = false +deselect_on_focus_loss_enabled = false +drag_and_drop_selection_enabled = false +virtual_keyboard_enabled = false + +[node name="icon" type="TextureRect" parent="MarginContainer/status_bar/SKIPPED/value" unique_id=932936779] +layout_mode = 1 +anchors_preset = 4 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 4.0 +offset_top = -8.0 +offset_right = 20.0 +offset_bottom = 8.0 +grow_vertical = 2 +size_flags_horizontal = 4 +size_flags_vertical = 4 +stretch_mode = 2 + +[node name="buttons" type="VBoxContainer" parent="MarginContainer/status_bar/SKIPPED" unique_id=1650258492] +layout_mode = 2 +size_flags_horizontal = 0 + +[node name="up" type="TextureButton" parent="MarginContainer/status_bar/SKIPPED/buttons" unique_id=357027687] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 10 + +[node name="down" type="TextureButton" parent="MarginContainer/status_bar/SKIPPED/buttons" unique_id=603266459] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 2 +flip_v = true + +[node name="ORPHAN" type="HBoxContainer" parent="MarginContainer/status_bar" unique_id=1284157443] +layout_mode = 2 +tooltip_text = "Detected Orphan Nodes" + +[node name="value" type="LineEdit" parent="MarginContainer/status_bar/ORPHAN" unique_id=629163801] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 4 +tooltip_text = "Detected Orphan Nodes" +text = "0" +alignment = 2 +max_length = 3 +editable = false +context_menu_enabled = false +emoji_menu_enabled = false +shortcut_keys_enabled = false +middle_mouse_paste_enabled = false +selecting_enabled = false +deselect_on_focus_loss_enabled = false +drag_and_drop_selection_enabled = false +virtual_keyboard_enabled = false + +[node name="icon" type="TextureRect" parent="MarginContainer/status_bar/ORPHAN/value" unique_id=2121368482] +layout_mode = 1 +anchors_preset = 4 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 4.0 +offset_top = -8.0 +offset_right = 20.0 +offset_bottom = 8.0 +grow_vertical = 2 +size_flags_horizontal = 4 +size_flags_vertical = 4 +stretch_mode = 2 + +[node name="buttons" type="VBoxContainer" parent="MarginContainer/status_bar/ORPHAN" unique_id=433516040] +layout_mode = 2 +size_flags_horizontal = 0 + +[node name="up" type="TextureButton" parent="MarginContainer/status_bar/ORPHAN/buttons" unique_id=159317457] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 10 + +[node name="down" type="TextureButton" parent="MarginContainer/status_bar/ORPHAN/buttons" unique_id=1357830825] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 2 +flip_v = true + +[connection signal="pressed" from="tree_tools/tree_buttons/btn_tree_sync" to="." method="_on_btn_tree_sync_pressed"] diff --git a/addons/gdUnit4/src/ui/parts/GdUnitInspectorToolBar.gd b/addons/gdUnit4/src/ui/parts/GdUnitInspectorToolBar.gd new file mode 100644 index 0000000..ffa6526 --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitInspectorToolBar.gd @@ -0,0 +1,104 @@ +@tool +extends Control + + +const GdUnitInspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd") + +@onready var _version_label: Control = %version +@onready var _button_wiki: Button = %help +@onready var _tool_button: Button = %tool +@onready var _button_run_overall: Button = %run_overall +@onready var _button_run: Button = %run +@onready var _button_run_debug: Button = %debug +@onready var _button_stop: Button = %stop + + +var inspector: GdUnitInspectorTreeMainPanel +var command_handler: GdUnitCommandHandler + + +func _ready() -> void: + command_handler = GdUnitCommandHandler.instance() + inspector = get_parent().get_parent().find_child("MainPanel", false, false) + if inspector == null: + push_error("Internal error, can't connect to the test inspector!") + else: + inspector.tree_item_selected.connect(_on_inspector_selected) + + GdUnit4Version.init_version_label(_version_label) + + GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event) + GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed) + _init_buttons() + + +func _init_buttons() -> void: + _init_button(_button_run_overall, GdUnitCommandRunTestsOverall.ID) + _init_button(_button_run, GdUnitCommandInspectorRunTests.ID) + _init_button(_button_run_debug, GdUnitCommandInspectorDebugTests.ID) + _init_button(_button_stop, GdUnitCommandStopTestSession.ID) + + _button_stop.icon = command_handler.command_icon(GdUnitCommandStopTestSession.ID) + _tool_button.icon = GdUnitUiTools.get_icon("Tools") + _button_wiki.icon = GdUnitUiTools.get_icon("HelpSearch") + # Set run buttons initial disabled + _button_run.disabled = true + _button_run_debug.disabled = true + + +func _init_button(button: Button, comand_id: String) -> void: + button.set_meta("GdUnitCommand", comand_id) + button.icon = command_handler.command_icon(comand_id) + button.shortcut = command_handler.command_shortcut(comand_id) + if button == _button_run_overall: + button.visible = GdUnitSettings.is_inspector_toolbar_button_show() + + +func _on_inspector_selected(item: TreeItem) -> void: + var button_disabled := item == null + _button_run.disabled = button_disabled + _button_run_debug.disabled = button_disabled + + +func _on_gdunit_event(event: GdUnitEvent) -> void: + if event.type() == GdUnitEvent.SESSION_START: + _button_run_overall.disabled = true + _button_run.disabled = true + _button_run_debug.disabled = true + _button_stop.disabled = false + return + if event.type() == GdUnitEvent.SESSION_CLOSE: + _button_run_overall.disabled = false + _button_stop.disabled = true + + +func _on_button_pressed(source: BaseButton) -> void: + var command_id: String = source.get_meta("GdUnitCommand") + await command_handler.command_execute(command_id) + + +func _on_wiki_pressed() -> void: + var status := OS.shell_open("https://godot-gdunit-labs.github.io/gdUnit4/latest") + if status != OK: + push_error("Can't open GdUnit4 documentaion page: %s" % error_string(status)) + + +func _on_btn_tool_pressed() -> void: + var settings_dlg: Window = EditorInterface.get_base_control().find_child("GdUnitSettingsDialog", false, false) + if settings_dlg == null: + settings_dlg = preload("res://addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn").instantiate() + EditorInterface.get_base_control().add_child(settings_dlg, true) + settings_dlg.popup_centered_ratio(.60) + + +func _on_settings_changed(property: GdUnitProperty) -> void: + # needs to wait a frame to be command handler notified first for settings changes + await get_tree().process_frame + + _button_run_overall.visible = GdUnitSettings.is_inspector_toolbar_button_show() + + if property.name().begins_with(GdUnitSettings.GROUP_SHORTCUT_INSPECTOR): + _button_run.shortcut = command_handler.command_shortcut(GdUnitCommandInspectorRunTests.ID) + _button_run_debug.shortcut = command_handler.command_shortcut(GdUnitCommandInspectorDebugTests.ID) + _button_run_overall.shortcut = command_handler.command_shortcut(GdUnitCommandRunTestsOverall.ID) + _button_stop.shortcut = command_handler.command_shortcut(GdUnitCommandStopTestSession.ID) diff --git a/addons/gdUnit4/src/ui/parts/GdUnitInspectorToolBar.gd.uid b/addons/gdUnit4/src/ui/parts/GdUnitInspectorToolBar.gd.uid new file mode 100644 index 0000000..e33fc8b --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitInspectorToolBar.gd.uid @@ -0,0 +1 @@ +uid://birjse4jr7jbc diff --git a/addons/gdUnit4/src/ui/parts/GdUnitInspectorToolBar.tscn b/addons/gdUnit4/src/ui/parts/GdUnitInspectorToolBar.tscn new file mode 100644 index 0000000..5ce9aa2 --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitInspectorToolBar.tscn @@ -0,0 +1,150 @@ +[gd_scene format=3 uid="uid://dx7xy4dgi3wwb"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/GdUnitInspectorToolBar.gd" id="3"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_c7rhl"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_3erui"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_p22nw"] + +[sub_resource type="InputEventKey" id="InputEventKey_4j3on"] +alt_pressed = true +pressed = true +keycode = 4194338 +physical_keycode = 4194338 + +[sub_resource type="Shortcut" id="Shortcut_5pa04"] +events = [SubResource("InputEventKey_4j3on")] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_3lcek"] + +[sub_resource type="InputEventKey" id="InputEventKey_85ykl"] +alt_pressed = true +pressed = true +keycode = 4194336 +physical_keycode = 4194336 + +[sub_resource type="Shortcut" id="Shortcut_fpubx"] +events = [SubResource("InputEventKey_85ykl")] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ndw0i"] + +[sub_resource type="InputEventKey" id="InputEventKey_1iti7"] +alt_pressed = true +pressed = true +keycode = 4194337 +physical_keycode = 4194337 + +[sub_resource type="Shortcut" id="Shortcut_hpi24"] +events = [SubResource("InputEventKey_1iti7")] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_eoihf"] + +[sub_resource type="InputEventKey" id="InputEventKey_hb3do"] +alt_pressed = true +pressed = true +keycode = 4194339 +physical_keycode = 4194339 + +[sub_resource type="Shortcut" id="Shortcut_lm2q6"] +events = [SubResource("InputEventKey_hb3do")] + +[node name="ToolBar" type="HBoxContainer" unique_id=295615556] +anchors_preset = 10 +anchor_right = 1.0 +offset_bottom = 32.0 +grow_horizontal = 2 +script = ExtResource("3") + +[node name="tools" type="HBoxContainer" parent="." unique_id=753222103] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 4 + +[node name="help" type="Button" parent="tools" unique_id=1834372497] +unique_name_in_owner = true +layout_mode = 2 +theme_override_styles/normal = SubResource("StyleBoxEmpty_c7rhl") + +[node name="tool" type="Button" parent="tools" unique_id=144277615] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "GdUnit Settings" +theme_override_styles/normal = SubResource("StyleBoxEmpty_3erui") + +[node name="controls" type="HBoxContainer" parent="." unique_id=108317981] +layout_mode = 2 +size_flags_horizontal = 6 +size_flags_vertical = 4 +alignment = 1 + +[node name="VSeparator3" type="VSeparator" parent="controls" unique_id=1244151035] +layout_mode = 2 + +[node name="run_overall" type="Button" parent="controls" unique_id=95775836] +unique_name_in_owner = true +use_parent_material = true +layout_mode = 2 +tooltip_text = "Run overall tests" +theme_override_styles/normal = SubResource("StyleBoxEmpty_p22nw") +shortcut = SubResource("Shortcut_5pa04") +metadata/GdUnitCommand = "Run Tests Overall" + +[node name="run" type="Button" parent="controls" unique_id=121281977] +unique_name_in_owner = true +use_parent_material = true +layout_mode = 2 +tooltip_text = "Rerun unit tests" +theme_override_styles/normal = SubResource("StyleBoxEmpty_3lcek") +disabled = true +shortcut = SubResource("Shortcut_fpubx") +metadata/GdUnitCommand = "Run Inspector Tests" + +[node name="debug" type="Button" parent="controls" unique_id=1188465777] +unique_name_in_owner = true +use_parent_material = true +layout_mode = 2 +tooltip_text = "Rerun unit tests (Debug)" +theme_override_styles/normal = SubResource("StyleBoxEmpty_ndw0i") +disabled = true +shortcut = SubResource("Shortcut_hpi24") +metadata/GdUnitCommand = "Debug Inspector Tests" + +[node name="stop" type="Button" parent="controls" unique_id=2086070257] +unique_name_in_owner = true +use_parent_material = true +layout_mode = 2 +tooltip_text = "Stops runing unit tests" +theme_override_styles/normal = SubResource("StyleBoxEmpty_eoihf") +disabled = true +shortcut = SubResource("Shortcut_lm2q6") +metadata/GdUnitCommand = "Stop Test Session" + +[node name="VSeparator4" type="VSeparator" parent="controls" unique_id=1661024234] +layout_mode = 2 + +[node name="CenterContainer" type="HBoxContainer" parent="." unique_id=1521483443] +use_parent_material = true +layout_mode = 2 +size_flags_horizontal = 10 +size_flags_vertical = 4 +alignment = 2 + +[node name="version" type="Label" parent="CenterContainer" unique_id=558200171] +unique_name_in_owner = true +auto_translate_mode = 2 +use_parent_material = true +layout_mode = 2 +size_flags_horizontal = 2 +size_flags_vertical = 13 +localize_numeral_system = false +horizontal_alignment = 1 +justification_flags = 160 + +[connection signal="pressed" from="tools/help" to="." method="_on_wiki_pressed"] +[connection signal="pressed" from="tools/tool" to="." method="_on_btn_tool_pressed"] +[connection signal="pressed" from="controls/run_overall" to="." method="_on_button_pressed" flags=18] +[connection signal="pressed" from="controls/run" to="." method="_on_button_pressed" flags=18] +[connection signal="pressed" from="controls/debug" to="." method="_on_button_pressed" flags=18] +[connection signal="pressed" from="controls/stop" to="." method="_on_button_pressed" flags=18] diff --git a/addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd b/addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd new file mode 100644 index 0000000..3d365f4 --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd @@ -0,0 +1,1238 @@ +@tool +extends Control + +## Will be emitted when the test index counter is changed +signal test_counters_changed(index: int, total: int, state: GdUnitInspectorTreeConstants.STATE) +signal tree_item_selected(item: TreeItem) + + +@onready var _tree: Tree = %Tree +@onready var _report_panel: GdUnitReportPanel = %report +@onready var _context_menu: GdUnitInspectorContextMenu = %contextMenu +@onready var _discover_hint: Control = %discover_hint +@onready var _spinner: Button = %spinner + +# loading tree icons +var ICON_SPINNER: Texture2D +var ICON_GD_SCRIPT: Texture2D +var ICON_CS_SCRIPT: Texture2D +var ICON_FOLDER: Texture2D + + +enum GdUnitType { + FOLDER, + TEST_SUITE, + TEST_CASE, + TEST_GROUP +} + +const META_GDUNIT_PROGRESS_COUNT_MAX := "gdUnit_progress_count_max" +const META_GDUNIT_PROGRESS_INDEX := "gdUnit_progress_index" +const META_TEST_CASE := "gdunit_test_case" +const META_GDUNIT_NAME := "gdUnit_name" +const META_GDUNIT_STATE := "gdUnit_state" +const META_GDUNIT_TYPE := "gdUnit_type" +const META_GDUNIT_SUCCESS_TESTS := "gdUnit_suite_success_tests" +const META_GDUNIT_REPORT := "gdUnit_report" +const META_GDUNIT_ORPHAN := "gdUnit_orphan" +const META_GDUNIT_EXECUTION_TIME := "gdUnit_execution_time" +const META_GDUNIT_ORIGINAL_INDEX = "gdunit_original_index" +const STATE = GdUnitInspectorTreeConstants.STATE + + +var _tree_root: TreeItem +var _current_selected_item: TreeItem = null +var _current_tree_view_mode := GdUnitSettings.get_inspector_tree_view_mode() +var _run_test_recovery := true + + +## Used for debugging purposes only +func print_tree_item_ids(parent: TreeItem) -> TreeItem: + for child in parent.get_children(): + if child.has_meta(META_TEST_CASE): + var test_case: GdUnitTestCase = child.get_meta(META_TEST_CASE) + prints(test_case.guid, test_case.test_name) + + if child.get_child_count() > 0: + print_tree_item_ids(child) + + return null + + +func _find_tree_item(parent: TreeItem, item_name: String) -> TreeItem: + for child in parent.get_children(): + if child.get_meta(META_GDUNIT_NAME) == item_name: + return child + return null + + +func _find_tree_item_by_id(parent: TreeItem, id: GdUnitGUID) -> TreeItem: + for child in parent.get_children(): + if is_test_id(child, id): + return child + if child.get_child_count() > 0: + var item := _find_tree_item_by_id(child, id) + if item != null: + return item + + return null + + +func _find_tree_item_by_test_suite(parent: TreeItem, suite_path: String, suite_name: String) -> TreeItem: + for child in parent.get_children(): + if child.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_SUITE: + var test_case: GdUnitTestCase = child.get_meta(META_TEST_CASE) + if test_case.suite_resource_path == suite_path and test_case.suite_name == suite_name: + return child + if child.get_child_count() > 0: + var item := _find_tree_item_by_test_suite(child, suite_path, suite_name) + if item != null: + return item + return null + + +func _find_first_item_by_state(parent: TreeItem, item_state: STATE, reverse := false) -> TreeItem: + var itmes := parent.get_children() + if reverse: + itmes.reverse() + + for item in itmes: + if item_state in [STATE.FAILED, STATE.ERROR] and get_item_reports(item).is_empty(): + var item_by_state := _find_first_item_by_state(item, item_state, reverse) + if item_by_state != null: + return item_by_state + + if is_item_state(item, item_state): + return item + var failure_item := _find_first_item_by_state(item, item_state, reverse) + if failure_item != null: + return failure_item + return null + + +func _find_last_item_by_state(parent: TreeItem, item_state: STATE) -> TreeItem: + return _find_first_item_by_state(parent, item_state, true) + + +func _find_item_by_state(current: TreeItem, item_state: STATE, prev := false) -> TreeItem: + var next := current.get_prev_in_tree() if prev else current.get_next_in_tree() + if next == null or next == _tree_root: + return null + + if item_state in [STATE.FAILED, STATE.ERROR] and get_item_reports(next).is_empty(): + return _find_item_by_state(next, item_state, prev) + + if is_item_state(next, item_state): + return next + return _find_item_by_state(next, item_state, prev) + + +func is_item_state(item: TreeItem, item_state: STATE) -> bool: + return get_item_state(item).has(item_state) + + +func is_state_running(item: TreeItem) -> bool: + return is_item_state(item, STATE.RUNNING) + + +func is_state_success(item: TreeItem) -> bool: + return is_item_state(item, STATE.SUCCESS) + + +func is_state_warning(item: TreeItem) -> bool: + return is_item_state(item, STATE.WARNING) + + +func is_state_failed(item: TreeItem) -> bool: + return is_item_state(item, STATE.FAILED) + + +func is_state_error(item: TreeItem) -> bool: + return is_item_state(item, STATE.ERROR) or is_item_state(item, STATE.ABORDED) + + +func is_item_state_orphan(item: TreeItem) -> bool: + return item.has_meta(META_GDUNIT_ORPHAN) + + +func is_test_suite(item: TreeItem) -> bool: + return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_SUITE + + +func is_test_case(item: TreeItem) -> bool: + return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_CASE + + +func is_test_group(item: TreeItem) -> bool: + return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_GROUP + + +func is_folder(item: TreeItem) -> bool: + return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.FOLDER + + +func is_test_id(item: TreeItem, id: GdUnitGUID) -> bool: + if not item.has_meta(META_TEST_CASE): + return false + + var test_case: GdUnitTestCase = item.get_meta(META_TEST_CASE) + return test_case.guid.equals(id) + + +func disable_test_recovery() -> void: + _run_test_recovery = false + + +@warning_ignore("return_value_discarded") +func _ready() -> void: + if Engine.is_editor_hint(): + var base_control := EditorInterface.get_base_control() + base_control.set_meta("GdUnit4Inspector", self) + + _init_icons() + init_tree() + GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed) + GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event) + GdUnitSignals.instance().gdunit_test_discover_added.connect(on_test_case_discover_added) + GdUnitSignals.instance().gdunit_test_discover_deleted.connect(on_test_case_discover_deleted) + GdUnitSignals.instance().gdunit_test_discover_modified.connect(on_test_case_discover_modified) + if _run_test_recovery: + GdUnitTestDiscoverer.restore_last_session() + + +func _notification(what: int) -> void: + if what == EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED: + _init_icons() + + +func _init_icons() -> void: + _spinner.icon = GdUnitUiTools.get_spinner() + ICON_GD_SCRIPT = GdUnitUiTools.get_icon("GDScript", GdUnitEditorColorTheme.state_initial) + ICON_CS_SCRIPT = GdUnitUiTools.get_icon("CSharpScript", GdUnitEditorColorTheme.state_initial) + ICON_FOLDER = GdUnitUiTools.get_icon("Folder", GdUnitEditorColorTheme.folder_color) + + +# we need current to manually redraw bacause of the animation bug +# https://github.com/godotengine/godot/issues/69330 +func _process(_delta: float) -> void: + if is_visible_in_tree(): + queue_redraw() + + +func init_tree() -> void: + cleanup_tree() + _tree.deselect_all() + _tree.set_hide_root(true) + _tree.ensure_cursor_is_visible() + _tree.set_allow_reselect(true) + _tree.set_allow_rmb_select(true) + _tree.set_columns(2) + _tree.set_column_clip_content(0, true) + _tree.set_column_expand_ratio(0, 1) + _tree.set_column_custom_minimum_width(0, 240) + _tree.set_column_expand_ratio(1, 0) + _tree.set_column_custom_minimum_width(1, 100) + _tree_root = _tree.create_item() + _tree_root.set_text(0, "tree_root") + _tree_root.set_meta(META_GDUNIT_NAME, "tree_root") + _tree_root.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0) + _tree_root.set_meta(META_GDUNIT_PROGRESS_INDEX, 0) + _tree_root.set_meta(META_GDUNIT_SUCCESS_TESTS, 0) + set_item_state(_tree_root, STATE.INITIAL) + # fix tree icon scaling + var scale_factor := EditorInterface.get_editor_scale() if Engine.is_editor_hint() else 1.0 + _tree.set("theme_override_constants/icon_max_width", 16 * scale_factor) + + +func cleanup_tree() -> void: + _report_panel.clear() + if not _tree_root: + return + _free_recursive() + _tree.clear() + _current_selected_item = null + + +func _free_recursive(items:=_tree_root.get_children()) -> void: + for item in items: + _free_recursive(item.get_children()) + item.call_deferred("free") + + +func sort_tree_items(parent: TreeItem) -> void: + _sort_tree_items(parent, GdUnitSettings.get_inspector_tree_sort_mode()) + _tree.queue_redraw() + + +static func _sort_tree_items(parent: TreeItem, sort_mode: GdUnitInspectorTreeConstants.SORT_MODE) -> void: + parent.visible = false + var items := parent.get_children() + # first remove all childs before sorting + for item in items: + parent.remove_child(item) + + # do sort by selected sort mode + match sort_mode: + GdUnitInspectorTreeConstants.SORT_MODE.UNSORTED: + items.sort_custom(sort_items_by_original_index) + + GdUnitInspectorTreeConstants.SORT_MODE.NAME_ASCENDING: + items.sort_custom(sort_items_by_name.bind(true)) + + GdUnitInspectorTreeConstants.SORT_MODE.NAME_DESCENDING: + items.sort_custom(sort_items_by_name.bind(false)) + + GdUnitInspectorTreeConstants.SORT_MODE.EXECUTION_TIME: + items.sort_custom(sort_items_by_execution_time) + + # readding sorted childs + for item in items: + parent.add_child(item) + if item.get_child_count() > 0: + _sort_tree_items(item, sort_mode) + parent.visible = true + + +static func sort_items_by_name(a: TreeItem, b: TreeItem, ascending: bool) -> bool: + var type_a: GdUnitType = a.get_meta(META_GDUNIT_TYPE) + var type_b: GdUnitType = b.get_meta(META_GDUNIT_TYPE) + + # Sort folders to the top + if type_a == GdUnitType.FOLDER and type_b != GdUnitType.FOLDER: + return true + if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER: + return false + + # sort by name + var name_a: String = a.get_meta(META_GDUNIT_NAME) + var name_b: String = b.get_meta(META_GDUNIT_NAME) + var comparison := name_a.naturalnocasecmp_to(name_b) + + return comparison < 0 if ascending else comparison > 0 + + +static func sort_items_by_execution_time(a: TreeItem, b: TreeItem) -> bool: + var type_a: GdUnitType = a.get_meta(META_GDUNIT_TYPE) + var type_b: GdUnitType = b.get_meta(META_GDUNIT_TYPE) + + # Sort folders to the top + if type_a == GdUnitType.FOLDER and type_b != GdUnitType.FOLDER: + return true + if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER: + return false + + var execution_time_a :int = a.get_meta(META_GDUNIT_EXECUTION_TIME) + var execution_time_b :int = b.get_meta(META_GDUNIT_EXECUTION_TIME) + # if has same execution time sort by name + if execution_time_a == execution_time_b: + var name_a :String = a.get_meta(META_GDUNIT_NAME) + var name_b :String = b.get_meta(META_GDUNIT_NAME) + return name_a.naturalnocasecmp_to(name_b) > 0 + return execution_time_a > execution_time_b + + +static func sort_items_by_original_index(a: TreeItem, b: TreeItem) -> bool: + var type_a: GdUnitType = a.get_meta(META_GDUNIT_TYPE) + var type_b: GdUnitType = b.get_meta(META_GDUNIT_TYPE) + + # Sort folders to the top + if type_a == GdUnitType.FOLDER and type_b != GdUnitType.FOLDER: + return true + if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER: + return false + + var index_a :int = a.get_meta(META_GDUNIT_ORIGINAL_INDEX) + var index_b :int = b.get_meta(META_GDUNIT_ORIGINAL_INDEX) + + # Sorting by index + return index_a < index_b + + +func restructure_tree(parent: TreeItem, tree_mode: GdUnitInspectorTreeConstants.TREE_VIEW_MODE) -> void: + _current_tree_view_mode = tree_mode + + match tree_mode: + GdUnitInspectorTreeConstants.TREE_VIEW_MODE.FLAT: + restructure_tree_to_flat(parent) + GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE: + restructure_tree_to_tree(parent) + recalculate_counters(_tree_root) + # finally apply actual sort mode + sort_tree_items(_tree_root) + + +# Restructure into flat mode +func restructure_tree_to_flat(parent: TreeItem) -> void: + var folders := flatmap_folders(parent) + # Store current folder paths and their test suites + for folder_path: String in folders: + var test_suites: Array[TreeItem] = folders[folder_path] + if test_suites.is_empty(): + continue + + # Create flat folder and move test suites into it + var folder := _tree.create_item(parent) + folder.set_meta(META_GDUNIT_NAME, folder_path) + update_item_total_counter(folder) + set_state_initial(folder, GdUnitType.FOLDER) + + # Move test suites under the flat folder + for test_suite in test_suites: + var old_parent := test_suite.get_parent() + old_parent.remove_child(test_suite) + folder.add_child(test_suite) + + # Cleanup old folder structure + cleanup_empty_folders(parent) + + +# Restructure into hierarchical tree mode +func restructure_tree_to_tree(parent: TreeItem) -> void: + var items_to_process := parent.get_children().duplicate() + + for item: TreeItem in items_to_process: + if is_folder(item): + var folder_path: String = item.get_meta(META_GDUNIT_NAME) + var parts := folder_path.split("/") + + if parts.size() > 1: + var current_parent := parent + # Build folder hierarchy + for part in parts: + var next := _find_tree_item(current_parent, part) + if not next: + next = _tree.create_item(current_parent) + next.set_meta(META_GDUNIT_NAME, part) + set_state_initial(next, GdUnitType.FOLDER) + current_parent = next + + # Move test suites to deepest folder + var test_suites := item.get_children() + for test_suite in test_suites: + item.remove_child(test_suite) + current_parent.add_child(test_suite) + + # Remove the flat folder + item.get_parent().remove_child(item) + item.free() + + +func flatmap_folders(parent: TreeItem) -> Dictionary: + var folder_map := {} + + for item in parent.get_children(): + if is_folder(item): + var current_path: String = item.get_meta(META_GDUNIT_NAME) + # Get parent folder paths + var parent_path := get_parent_folder_path(item) + if parent_path: + current_path = parent_path + "/" + current_path + + # Collect direct children of this folder + var children: Array[TreeItem] = [] + for child in item.get_children(): + if is_test_suite(child): + children.append(child) + + # Add children to existing path or create new entry + if not children.is_empty(): + if folder_map.has(current_path): + @warning_ignore("unsafe_method_access") + folder_map[current_path].append_array(children) + else: + folder_map[current_path] = children + + # Recursively process subfolders + var sub_folders := flatmap_folders(item) + for path: String in sub_folders.keys(): + if folder_map.has(path): + @warning_ignore("unsafe_method_access") + folder_map[path].append_array(sub_folders[path]) + else: + folder_map[path] = sub_folders[path] + return folder_map + + +func get_parent_folder_path(item: TreeItem) -> String: + var path := "" + var parent := item.get_parent() + + while parent != _tree_root: + if is_folder(parent): + path = parent.get_meta(META_GDUNIT_NAME) + ("/" + path if path else "") + parent = parent.get_parent() + + return path + + +func cleanup_empty_folders(parent: TreeItem) -> void: + var folders: Array[TreeItem] = [] + # First collect all folders to avoid modification during iteration + for item in parent.get_children(): + if is_folder(item): + folders.append(item) + + # Process collected folders + for folder in folders: + cleanup_empty_folders(folder) + # Remove folder if it has no children after cleanup + if folder.get_child_count() == 0: + parent.remove_child(folder) + folder.free() + + +func reset_tree_state(parent: TreeItem) -> void: + if parent == _tree_root: + _tree_root.set_meta(META_GDUNIT_PROGRESS_INDEX, 0) + set_item_state(_tree_root, STATE.INITIAL) + test_counters_changed.emit(0, 0, STATE.INITIAL) + + for item in parent.get_children(): + set_state_initial(item, get_item_type(item)) + reset_tree_state(item) + + +func select_item(item: TreeItem) -> TreeItem: + if item != null: + # enshure the parent is collapsed + do_collapse_parent(item) + item.select(0) + _tree.ensure_cursor_is_visible() + _tree.scroll_to_item(item, true) + return item + + +func do_collapse_parent(item: TreeItem) -> void: + if item != null: + item.collapsed = false + do_collapse_parent(item.get_parent()) + + +func do_collapse_all(collapse: bool, parent := _tree_root) -> void: + for item in parent.get_children(): + item.collapsed = collapse + if not collapse: + do_collapse_all(collapse, item) + + +func set_state_initial(item: TreeItem, type: GdUnitType) -> void: + item.set_text(0, str(item.get_meta(META_GDUNIT_NAME))) + item.set_tooltip_text(0, "") + item.set_text_overrun_behavior(0, TextServer.OVERRUN_TRIM_CHAR) + item.set_expand_right(0, true) + + item.set_text(1, "") + item.set_expand_right(1, true) + item.set_tooltip_text(1, "") + item.set_meta(META_GDUNIT_TYPE, type) + item.set_meta(META_GDUNIT_SUCCESS_TESTS, 0) + item.set_meta(META_GDUNIT_EXECUTION_TIME, 0) + if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX) and item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX) > 0: + item.set_text(0, "(0/%d) %s" % [item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)]) + item.remove_meta(META_GDUNIT_REPORT) + item.remove_meta(META_GDUNIT_ORPHAN) + + set_item_state(item, STATE.INITIAL) + + +func set_state_running(item: TreeItem) -> void: + if is_state_running(item): + return + if is_item_state(item, STATE.INITIAL): + set_item_state(item, STATE.RUNNING) + item.collapsed = false + + var parent := item.get_parent() + if parent != _tree_root: + set_state_running(parent) + + +func set_state_succeded(item: TreeItem) -> void: + # Do not overwrite higher states + if is_state_error(item) or is_state_failed(item): + return + if item == _tree_root: + return + item.collapsed = GdUnitSettings.is_inspector_node_collapse() + set_item_state(item, STATE.SUCCESS) + + +func set_state_flaky(item: TreeItem, event: GdUnitEvent) -> void: + # Do not overwrite higher states + if is_state_error(item): + return + var retry_count := event.statistic(GdUnitEvent.RETRY_COUNT) + if retry_count > 1: + var item_text: String = item.get_meta(META_GDUNIT_NAME) + if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX): + var success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS) + item_text = "(%d/%d) %s" % [success_count, item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)] + item.set_text(0, "%s (%s retries)" % [item_text, retry_count]) + + item.collapsed = false + set_item_state(item, STATE.FLAKY) + + +func set_state_skipped(item: TreeItem) -> void: + item.set_text(1, "(skipped)") + item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT) + item.collapsed = false + set_item_state(item, STATE.SKIPPED) + + +func set_state_warnings(item: TreeItem) -> void: + # Do not overwrite higher states + if is_state_error(item) or is_state_failed(item): + return + + item.collapsed = false + set_item_state(item, STATE.WARNING) + + +func set_state_failed(item: TreeItem, event: GdUnitEvent) -> void: + # Do not overwrite higher states + if is_state_error(item): + return + var retry_count := event.statistic(GdUnitEvent.RETRY_COUNT) + if retry_count > 1: + var item_text: String = item.get_meta(META_GDUNIT_NAME) + if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX): + var success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS) + item_text = "(%d/%d) %s" % [success_count, item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)] + item.set_text(0, "%s (%s retries)" % [item_text, retry_count]) + item.collapsed = false + set_item_state(item, STATE.FAILED) + + +func set_state_error(item: TreeItem) -> void: + set_item_state(item, STATE.ERROR) + item.collapsed = false + + +func set_state_aborted(item: TreeItem) -> void: + item.set_text(1, "(aborted)") + item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT) + set_item_state(item, STATE.ABORDED) + item.collapsed = false + + +func set_state_orphan(item: TreeItem, event: GdUnitEvent) -> void: + var orphan_count := event.statistic(GdUnitEvent.ORPHAN_NODES) + if orphan_count == 0: + return + + set_item_state(item, STATE.ORPHAN) + item.set_meta(META_GDUNIT_ORPHAN, orphan_count) + item.set_tooltip_text(0, "Total <%d> orphan nodes detected." % orphan_count) + + +func update_state(item: TreeItem, event: GdUnitEvent) -> void: + # we do not show the root + if item == null: + return + + if event.is_skipped(): + set_state_skipped(item) + elif event.is_success() and event.is_flaky(): + set_state_flaky(item, event) + elif event.is_success(): + set_state_succeded(item) + elif event.is_error(): + set_state_error(item) + elif event.is_failed(): + set_state_failed(item, event) + elif event.is_warning(): + set_state_warnings(item) + set_state_orphan(item, event) + + +func add_report(item: TreeItem, report: GdUnitReport) -> void: + var reports: Array[GdUnitReport] = [] + if item.has_meta(META_GDUNIT_REPORT): + reports = get_item_reports(item) + reports.append(report) + item.set_meta(META_GDUNIT_REPORT, reports) + + +func add_reports(item: TreeItem, reports: Array[GdUnitReport]) -> void: + for report in reports: + add_report(item, report) + + +func abort_running(items := _tree_root.get_children()) -> void: + for item in items: + if is_state_running(item): + if is_test_case(item): + set_state_aborted(item) + else: + var state := _find_highest_state(item) + if state == STATE.RUNNING: + set_state_aborted(item) + else: + set_item_state(item, state) + abort_running(item.get_children()) + + +func _on_select_next_item_by_state(item_state: int) -> TreeItem: + var current_selected := _tree.get_selected() + # If nothing is selected, the first error is selected or the next one in the vicinity of the current selection is found + current_selected = (_find_first_item_by_state(_tree_root, item_state) + if current_selected == null + else _find_item_by_state(current_selected, item_state)) + # If no next failure found, then we try to select first + if current_selected == null: + current_selected = _find_first_item_by_state(_tree_root, item_state) + return select_item(current_selected) + + +func _on_select_previous_item_by_state(item_state: int) -> TreeItem: + var current_selected := _tree.get_selected() + # If nothing is selected, the first error is selected or the next one in the vicinity of the current selection is found + current_selected = _find_last_item_by_state(_tree_root, item_state) if current_selected == null else _find_item_by_state(current_selected, item_state, true) + # If no next failure found, then we try to select first last + if current_selected == null: + current_selected = _find_last_item_by_state(_tree_root, item_state) + return select_item(current_selected) + + +func select_first_orphan() -> void: + for parent in _tree_root.get_children(): + if not is_state_success(parent): + for item in parent.get_children(): + if is_item_state_orphan(item): + parent.set_collapsed(false) + @warning_ignore("return_value_discarded") + select_item(item) + return + + +func update_test_suite(event: GdUnitEvent) -> void: + var item := _find_tree_item_by_test_suite(_tree_root, event.resource_path(), event.suite_name()) + if not item: + push_error("[InspectorTreeMainPanel#update_test_suite] Internal Error: Can't find test suite item '{_suite_name}' for {_resource_path} ".format(event)) + return + + update_item_state_recursive(item) + update_item_elapsed_time_counter_recursive(item, event.elapsed_time()) + update_item_orphan_counter_recursive(item) + # update the state and add possible reports + update_state(item, event) + add_reports(item, event.reports()) + + +func update_test_case(event: GdUnitEvent) -> void: + var item := _find_tree_item_by_id(_tree_root, event.guid()) + if not item: + #push_error("Internal Error: Can't find test id %s" % [event.guid()]) + return + if event.type() == GdUnitEvent.TESTCASE_BEFORE: + set_state_running(item) + # force scrolling to current test case + _tree.scroll_to_item(item, true) + return + + if event.type() == GdUnitEvent.TESTCASE_AFTER: + update_item_elapsed_time_counter_recursive(item, event.elapsed_time()) + if event.is_success() or event.is_warning(): + update_item_processed_counter(item) + # update the state and add possible reports + update_state(item, event) + update_progress_counters(item) + # update test group + if is_test_group(item.get_parent()): + update_state(item.get_parent(), event) + add_reports(item, event.reports()) + + +func create_item(parent: TreeItem, test: GdUnitTestCase, item_name: String, type: GdUnitType) -> TreeItem: + var item := _tree.create_item(parent) + item.collapsed = true + item.set_meta(META_GDUNIT_ORIGINAL_INDEX, item.get_index()) + item.set_text(0, item_name) + match type: + GdUnitType.TEST_CASE: + item.set_meta(META_TEST_CASE, test) + GdUnitType.TEST_GROUP: + # We need to create a copy of the test record meta with a new uniqe guid + item.set_meta(META_TEST_CASE, GdUnitTestCase.from(test.suite_resource_path, test.source_file, test.line_number, test.test_name)) + GdUnitType.TEST_SUITE: + # We need to create a copy of the test record meta with a new uniqe guid + item.set_meta(META_TEST_CASE, GdUnitTestCase.from(test.suite_resource_path, test.source_file, test.line_number, test.suite_name)) + + item.set_meta(META_GDUNIT_NAME, item_name) + set_state_initial(item, type) + update_item_total_counter(item) + return item + + +static func get_item_state(item: TreeItem) -> Array[STATE]: + if item.has_meta(META_GDUNIT_STATE): + return item.get_meta(META_GDUNIT_STATE) + return [STATE.INITIAL, STATE.INITIAL] + + +static func get_item_orphans(item: TreeItem) -> int: + if item.has_meta(META_GDUNIT_ORPHAN): + return item.get_meta(META_GDUNIT_ORPHAN) + return 0 + + +func get_item_children_orphans(item: TreeItem) -> int: + var orphans_total := 0 + for child_item in item.get_children(): + orphans_total += get_item_orphans(child_item) + return orphans_total + + +func get_item_children_elapsed_time(item: TreeItem) -> int: + var elapsed_time_total := 0 + for child_item in item.get_children(): + elapsed_time_total += child_item.get_meta(META_GDUNIT_EXECUTION_TIME) + return elapsed_time_total + + +func set_item_state(item: TreeItem, state: STATE) -> void: + if item == _tree_root: + return + + if state != STATE.RUNNING and is_folder(item): + var resource_path := get_item_source_file(item) + item.set_icon(0, get_icon_by_file_type(resource_path)) + else: + item.set_icon(0, GdUnitUiTools.get_state_icon(state)) + + if state == STATE.INITIAL: + var inital_state: Array[STATE] = [STATE.INITIAL, STATE.INITIAL] + item.set_meta(META_GDUNIT_STATE, inital_state) + elif state == STATE.ORPHAN: + get_item_state(item)[1] = state + else: + get_item_state(item)[0] = state + + set_item_state_color(item, state) + + +func set_item_state_color(item: TreeItem, state: STATE) -> void: + if item == _tree_root: + return + + var state_color := GdUnitEditorColorTheme.state_initial + match state: + STATE.INITIAL: + state_color = GdUnitEditorColorTheme.state_initial + STATE.RUNNING, STATE.SUCCESS: + state_color = GdUnitEditorColorTheme.state_success + STATE.WARNING: + state_color = GdUnitEditorColorTheme.state_warning + STATE.FLAKY: + state_color = GdUnitEditorColorTheme.state_flaky + STATE.ERROR, STATE.ABORDED: + state_color = GdUnitEditorColorTheme.state_error + STATE.FAILED: + state_color = GdUnitEditorColorTheme.state_failure + STATE.SKIPPED: + state_color = GdUnitEditorColorTheme.state_skipped + STATE.ORPHAN: + # For orphan state we do not overwrite the orignal color state + return + + item.set_custom_color(0, state_color) + item.set_custom_color(1, state_color) + + +func update_item_total_counter(item: TreeItem) -> void: + if item == null: + return + + var child_count := get_total_child_count(item) + if child_count > 0: + item.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, child_count) + item.set_text(0, "(0/%d) %s" % [child_count, item.get_meta(META_GDUNIT_NAME)]) + + update_item_total_counter(item.get_parent()) + + +func get_total_child_count(item: TreeItem) -> int: + var total_count := 0 + for child in item.get_children(): + total_count += child.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX) if child.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX) else 1 + return total_count + + +func update_item_processed_counter(item: TreeItem, add_count := 1) -> void: + if item == _tree_root: + return + + var success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS) + add_count + item.set_meta(META_GDUNIT_SUCCESS_TESTS, success_count) + if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX): + item.set_text(0, "(%d/%d) %s" % [success_count, item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)]) + + update_item_processed_counter(item.get_parent(), add_count) + + +func update_progress_counters(item: TreeItem) -> void: + var index: int = _tree_root.get_meta(META_GDUNIT_PROGRESS_INDEX) + 1 + var total_test: int = _tree_root.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX) + var state := get_item_state(item)[0] + test_counters_changed.emit(index, total_test, state) + _tree_root.set_meta(META_GDUNIT_PROGRESS_INDEX, index) + + +func recalculate_counters(parent: TreeItem) -> void: + # Reset the counter first + if parent.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX): + parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0) + if parent.has_meta(META_GDUNIT_PROGRESS_INDEX): + parent.set_meta(META_GDUNIT_PROGRESS_INDEX, 0) + if parent.has_meta(META_GDUNIT_SUCCESS_TESTS): + parent.set_meta(META_GDUNIT_SUCCESS_TESTS, 0) + + # Calculate new count based on children + var total_count := 0 + var success_count := 0 + var progress_index := 0 + + for child in parent.get_children(): + if child.get_child_count() > 0: + # Recursively update child counters first + recalculate_counters(child) + # Add child's counters to parent + if child.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX): + total_count += child.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX) + if child.has_meta(META_GDUNIT_SUCCESS_TESTS): + success_count += child.get_meta(META_GDUNIT_SUCCESS_TESTS) + if child.has_meta(META_GDUNIT_PROGRESS_INDEX): + progress_index += child.get_meta(META_GDUNIT_PROGRESS_INDEX) + elif is_test_case(child): + # Count individual test cases + total_count += 1 + # Count completed tests + if is_state_success(child) or is_state_warning(child) or is_state_failed(child) or is_state_error(child): + progress_index += 1 + if is_state_success(child) or is_state_warning(child): + success_count += 1 + + # Update the counters + if total_count > 0: + parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, total_count) + parent.set_meta(META_GDUNIT_PROGRESS_INDEX, progress_index) + parent.set_meta(META_GDUNIT_SUCCESS_TESTS, success_count) + + # Update the display text + parent.set_text(0, "(%d/%d) %s" % [success_count, total_count, parent.get_meta(META_GDUNIT_NAME)]) + + +func update_item_elapsed_time_counter_recursive(item: TreeItem, elapsed_time: int) -> void: + if item == _tree_root: + return + if is_folder(item) or is_test_group(item): + elapsed_time = get_item_children_elapsed_time(item) + + item.set_text(1, "%s" % LocalTime.elapsed(elapsed_time)) + item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT) + item.set_meta(META_GDUNIT_EXECUTION_TIME, elapsed_time) + update_item_elapsed_time_counter_recursive(item.get_parent(), elapsed_time) + + +func update_item_orphan_counter_recursive(item: TreeItem) -> void: + if item == _tree_root: + return + + var orphan_count := 0 + var type: GdUnitType = item.get_meta(META_GDUNIT_TYPE) + match type: + GdUnitType.TEST_CASE: + return + GdUnitType.TEST_SUITE: + orphan_count = get_item_children_orphans(item) + get_item_orphans(item) + if orphan_count > 0: + set_item_state(item, STATE.ORPHAN) + GdUnitType.FOLDER: + orphan_count = get_item_children_orphans(item) + + if orphan_count == 0: + return + item.set_meta(META_GDUNIT_ORPHAN, orphan_count) + item.set_tooltip_text(0, "Total <%d> orphan nodes detected." % orphan_count) + update_item_orphan_counter_recursive(item.get_parent()) + + +func update_item_state_recursive(item: TreeItem) -> void: + if item == _tree_root: + return + + var state := _find_highest_state(item) + set_item_state(item, state) + + update_item_state_recursive(item.get_parent()) + + +func _find_highest_state(item: TreeItem) -> STATE: + var state := STATE.INITIAL + for child in item.get_children(): + var item_state := get_item_state(child)[0] + if item_state > state: + state = item_state + return state + + + +func get_icon_by_file_type(path: String) -> Texture2D: + if path.get_extension() == "gd": + return ICON_GD_SCRIPT + if path.get_extension() == "cs": + return ICON_CS_SCRIPT + return ICON_FOLDER + + +func on_test_case_discover_added(test_case: GdUnitTestCase) -> void: + var test_root_folder := GdUnitSettings.test_root_folder().replace("res://", "") + var fully_qualified_name := test_case.fully_qualified_name.trim_suffix(test_case.display_name) + var parts := fully_qualified_name.split(".", false) + parts.append(test_case.display_name) + # Skip tree structure until test root folder + var index := parts.find(test_root_folder) + if index != -1: + parts = parts.slice(index+1) + + match _current_tree_view_mode: + GdUnitInspectorTreeConstants.TREE_VIEW_MODE.FLAT: + create_items_tree_mode_flat(test_case, parts) + GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE: + create_items_tree_mode_tree(test_case, parts) + + +func create_items_tree_mode_tree(test_case: GdUnitTestCase, parts: PackedStringArray) -> void: + var parent := _tree_root + var is_suite_assigned := false + var suite_name := test_case.suite_name.split(".")[-1] + for item_name in parts: + var next := _find_tree_item(parent, item_name) + if next != null: + parent = next + continue + + if not is_suite_assigned and suite_name == item_name: + next = create_item(parent, test_case, item_name, GdUnitType.TEST_SUITE) + is_suite_assigned = true + elif item_name == test_case.display_name: + next = create_item(parent, test_case, item_name, GdUnitType.TEST_CASE) + # On grouped tests (parameterized tests) + elif item_name == test_case.test_name: + next = create_item(parent, test_case, item_name, GdUnitType.TEST_GROUP) + else: + next = create_item(parent, test_case, item_name, GdUnitType.FOLDER) + parent = next + + +func create_items_tree_mode_flat(test_case: GdUnitTestCase, parts: PackedStringArray) -> void: + # All parts except the last two (suite name and test name/display name) + var slice_index := -2 if parts[-1] == test_case.test_name else -3 + var path_parts := parts.slice(0, slice_index) + var folder_path := "/".join(path_parts) + + # Find or create flat folder + var folder_item: TreeItem + if folder_path.is_empty(): + folder_item = _tree_root + else: + folder_item = _find_tree_item(_tree_root, folder_path) + if folder_item == null: + folder_item = create_item(_tree_root, test_case, folder_path, GdUnitType.FOLDER) + + # Find suite under the flat folder (second to last part) + var suite_item := _find_tree_item(folder_item, test_case.suite_name) + if suite_item == null: + suite_item = create_item(folder_item, test_case, test_case.suite_name, GdUnitType.TEST_SUITE) + + # Add test case or group under the suite + if test_case.test_name != test_case.display_name: + # It's a parameterized test group + var group_item := _find_tree_item(suite_item, test_case.test_name) + if group_item == null: + group_item = create_item(suite_item, test_case, test_case.test_name, GdUnitType.TEST_GROUP) + create_item(group_item, test_case, test_case.display_name, GdUnitType.TEST_CASE) + else: + create_item(suite_item, test_case, test_case.display_name, GdUnitType.TEST_CASE) + + +func on_test_case_discover_deleted(test_case: GdUnitTestCase) -> void: + var item := _find_tree_item_by_id(_tree_root, test_case.guid) + if item != null: + var parent := item.get_parent() + parent.remove_child(item) + + # update the cached counters + var item_success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS) + var item_total_test_count: int = item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0) + var total_test_count: int = parent.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0) + parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, total_test_count-item_total_test_count) + + # propagate counter update to all parents + update_item_total_counter(parent) + update_item_processed_counter(parent, -item_success_count) + + +func on_test_case_discover_modified(test_case: GdUnitTestCase) -> void: + var item := _find_tree_item_by_id(_tree_root, test_case.guid) + if item != null: + item.set_meta(META_TEST_CASE, test_case) + item.set_text(0, test_case.display_name) + item.set_meta(META_GDUNIT_NAME, test_case.display_name) + + +func get_item_reports(item: TreeItem) -> Array[GdUnitReport]: + if item == null or not item.has_meta(META_GDUNIT_REPORT): + return [] + return item.get_meta(META_GDUNIT_REPORT) + + +func get_item_test_line_number(item: TreeItem) -> int: + if item == null or not item.has_meta(META_TEST_CASE): + return -1 + + var test_case: GdUnitTestCase = item.get_meta(META_TEST_CASE) + return test_case.line_number + + +func get_item_source_file(item: TreeItem) -> String: + if item == null or not item.has_meta(META_TEST_CASE): + return "" + + var test_case: GdUnitTestCase = item.get_meta(META_TEST_CASE) + return test_case.source_file + + +func get_item_type(item: TreeItem) -> GdUnitType: + if item == null or not item.has_meta(META_GDUNIT_TYPE): + return GdUnitType.FOLDER + return item.get_meta(META_GDUNIT_TYPE) + + +func _dump_tree_as_json(dump_name: String) -> void: + var dict := _to_json(_tree_root) + var file := FileAccess.open("res://%s.json" % dump_name, FileAccess.WRITE) + file.store_string(JSON.stringify(dict, "\t")) + + +func _to_json(parent :TreeItem) -> Dictionary: + var item_as_dict := GdObjects.obj2dict(parent) + item_as_dict["TreeItem"]["childrens"] = parent.get_children().map(func(item: TreeItem) -> Dictionary: + return _to_json(item)) + return item_as_dict + + +func extract_resource_path(event: GdUnitEvent) -> String: + return ProjectSettings.localize_path(event.resource_path()) + + +func collect_test_cases(item: TreeItem, tests: Array[GdUnitTestCase] = []) -> Array[GdUnitTestCase]: + for next in item.get_children(): + collect_test_cases(next, tests) + + if is_test_case(item): + var test: GdUnitTestCase = item.get_meta(META_TEST_CASE) + if not tests.has(test): + tests.append(test) + + return tests + + +func test_session_start() -> void: + _context_menu.disable_items() + reset_tree_state(_tree_root) + _report_panel.clear() + + +func test_session_stop() -> void: + _context_menu.enable_items() + abort_running() + sort_tree_items(_tree_root) + # wait until the tree redraw + await get_tree().process_frame + var failure_item := _find_first_item_by_state(_tree_root, STATE.FAILED) + select_item( failure_item if failure_item else _current_selected_item) + + +################################################################################ +# Tree signal receiver +################################################################################ +func _on_tree_item_mouse_selected(mouse_position: Vector2, mouse_button_index: int) -> void: + if mouse_button_index == MOUSE_BUTTON_RIGHT: + _context_menu.position = get_screen_position() + mouse_position + _context_menu.popup() + + +func _on_Tree_item_selected() -> void: + _current_selected_item = _tree.get_selected() + var reports := get_item_reports(_current_selected_item) + _report_panel.show_report(reports) + tree_item_selected.emit(_current_selected_item) + + +# Opens the test suite +func _on_Tree_item_activated() -> void: + var selected_item := _tree.get_selected() + var line_number := get_item_test_line_number(selected_item) + if line_number != -1: + var script_path := ProjectSettings.localize_path(get_item_source_file(selected_item)) + var resource: Script = load(script_path) + + if selected_item.has_meta(META_GDUNIT_REPORT): + var reports := get_item_reports(selected_item) + var report_line_number := reports[0].line_number() + # if number -1 we use original stored line number of the test case + # in non debug mode the line number is not available + if report_line_number != -1: + line_number = report_line_number + + EditorInterface.get_file_system_dock().navigate_to_path(script_path) + EditorInterface.edit_script(resource, line_number) + elif selected_item.get_meta(META_GDUNIT_TYPE) == GdUnitType.FOLDER: + # Toggle collapse if dir + selected_item.collapsed = not selected_item.collapsed + + +################################################################################ +# external signal receiver +################################################################################ + +func _on_gdunit_event(event: GdUnitEvent) -> void: + match event.type(): + GdUnitEvent.DISCOVER_START: + _tree_root.visible = false + _discover_hint.visible = true + init_tree() + + GdUnitEvent.DISCOVER_END: + sort_tree_items(_tree_root) + select_item(_tree_root.get_first_child()) + _discover_hint.visible = false + _tree_root.visible = true + #_dump_tree_as_json("tree_example_discovered") + + GdUnitEvent.TESTCASE_BEFORE: + update_test_case(event) + + GdUnitEvent.TESTCASE_AFTER: + update_test_case(event) + + GdUnitEvent.TESTSUITE_AFTER: + update_test_suite(event) + + GdUnitEvent.SESSION_START: + test_session_start() + + GdUnitEvent.SESSION_CLOSE: + await test_session_stop() + + +func _on_settings_changed(property :GdUnitProperty) -> void: + match property.name(): + GdUnitSettings.INSPECTOR_TREE_SORT_MODE: + sort_tree_items(_tree_root) + #_dump_tree_as_json("tree_sorted_by_%s" % GdUnitInspectorTreeConstants.SORT_MODE.keys()[property.value()]) + + GdUnitSettings.INSPECTOR_TREE_VIEW_MODE: + restructure_tree(_tree_root, GdUnitSettings.get_inspector_tree_view_mode()) diff --git a/addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd.uid b/addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd.uid new file mode 100644 index 0000000..64401dd --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd.uid @@ -0,0 +1 @@ +uid://7elllplde43b diff --git a/addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.tscn b/addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.tscn new file mode 100644 index 0000000..24b6c66 --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.tscn @@ -0,0 +1,133 @@ +[gd_scene format=3 uid="uid://bqfpidewtpeg0"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/GdUnitInspectorTreeMainPanel.gd" id="1"] +[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.tscn" id="2_o6s0p"] +[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/GdUnitReportPanel.tscn" id="3_miuuy"] + +[sub_resource type="DPITexture" id="DPITexture_njcuo"] +_source = " +" +saturation = 2.0 +color_map = { +Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1), +Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1), +Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1) +} + +[sub_resource type="DPITexture" id="DPITexture_o05pk"] +_source = " +" +saturation = 2.0 +color_map = { +Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1), +Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1), +Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1) +} + +[sub_resource type="DPITexture" id="DPITexture_njtpf"] +_source = " +" +saturation = 2.0 +color_map = { +Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1), +Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1), +Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1) +} + +[sub_resource type="DPITexture" id="DPITexture_eg3rq"] +_source = " +" +saturation = 2.0 +color_map = { +Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1), +Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1), +Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1) +} + +[node name="MainPanel" type="Control" unique_id=944258025] +custom_minimum_size = Vector2(120, 240) +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1") + +[node name="contextMenu" parent="." unique_id=73764904 instance=ExtResource("2_o6s0p")] +unique_name_in_owner = true +visible = false +item_0/icon = SubResource("DPITexture_njcuo") +item_1/icon = SubResource("DPITexture_o05pk") +item_2/icon = SubResource("DPITexture_njcuo") +item_4/icon = SubResource("DPITexture_njtpf") +item_5/icon = SubResource("DPITexture_eg3rq") + +[node name="split" type="VSplitContainer" parent="." unique_id=1044515559] +clip_contents = true +custom_minimum_size = Vector2(0, 160) +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/autohide = 0 +split_offsets = PackedInt32Array(120) +split_offset = 120 + +[node name="Panel" type="Panel" parent="split" unique_id=290722553] +custom_minimum_size = Vector2(0, 120) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Tree" type="Tree" parent="split/Panel" unique_id=741772884] +unique_name_in_owner = true +use_parent_material = true +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/icon_max_width = 16 +columns = 2 +allow_reselect = true +allow_rmb_select = true +hide_root = true +select_mode = 1 + +[node name="discover_hint" type="HBoxContainer" parent="split/Panel" unique_id=555683311] +unique_name_in_owner = true +visible = false +use_parent_material = true +layout_mode = 0 +alignment = 1 + +[node name="spinner" type="Button" parent="split/Panel/discover_hint" unique_id=200472082] +unique_name_in_owner = true +clip_contents = true +custom_minimum_size = Vector2(64, 64) +layout_mode = 2 +size_flags_stretch_ratio = 1.94 +disabled = true +button_mask = 0 +text = "Discover Tests" +flat = true +alignment = 2 + +[node name="report" parent="split" unique_id=1465857421 instance=ExtResource("3_miuuy")] +unique_name_in_owner = true +custom_minimum_size = Vector2(120, 40) +layout_mode = 2 + +[connection signal="item_activated" from="split/Panel/Tree" to="." method="_on_Tree_item_activated"] +[connection signal="item_mouse_selected" from="split/Panel/Tree" to="." method="_on_tree_item_mouse_selected"] +[connection signal="item_selected" from="split/Panel/Tree" to="." method="_on_Tree_item_selected"] diff --git a/addons/gdUnit4/src/ui/parts/GdUnitReportPanel.gd b/addons/gdUnit4/src/ui/parts/GdUnitReportPanel.gd new file mode 100644 index 0000000..8b762c4 --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitReportPanel.gd @@ -0,0 +1,62 @@ +@tool +class_name GdUnitReportPanel +extends Panel + +@onready var report_list: Node = %report_list +@onready var message_template: RichTextLabel = %message + + +func clear() -> void: + for child in report_list.get_children(): + report_list.remove_child(child) + child.queue_free() + + +func show_report(reports: Array[GdUnitReport]) -> void: + clear() + for report in reports: + report_list.add_child(build_report(report)) + + +func build_report(report: GdUnitReport) -> RichTextLabel: + var message: RichTextLabel = message_template.duplicate() + GdUnitFonts.init_fonts(message) + message.push_color(GdUnitEditorColorTheme.text_color) + message.append_text(report.message()) + message.pop() + add_stack_trace(message, report.stack_trace()) + message.visible = true + return message + + +func add_stack_trace(message: RichTextLabel, trace: GdUnitStackTrace) -> void: + if trace == null: + return + message.newline() + for frame in trace.get_frames(): + message.push_indent(1) + message.push_meta(frame, RichTextLabel.META_UNDERLINE_ON_HOVER, frame._source) + message.push_color(GdUnitEditorColorTheme.text_color) + message.append_text("at ") + message.push_color(GdUnitEditorColorTheme.function_definition_color) + message.append_text(frame._function) + message.pop() + message.append_text(" in ") + message.pop() + + message.push_color(GdUnitEditorColorTheme.engine_type_color) + message.append_text(frame._source.get_file()) + message.append_text(" : ") + message.append_text(str(frame._line)) + message.pop() # push_color + message.pop() # push_meta + message.pop() # push_indent + message.newline() + + if not message.meta_clicked.is_connected(_on_meta_clicked): + message.meta_clicked.connect(_on_meta_clicked) + + +func _on_meta_clicked(meta: Variant) -> void: + var frame: GdUnitStackTraceElement = meta + GdUnitScriptEditorControls.edit_script(frame._source, frame._line) diff --git a/addons/gdUnit4/src/ui/parts/GdUnitReportPanel.gd.uid b/addons/gdUnit4/src/ui/parts/GdUnitReportPanel.gd.uid new file mode 100644 index 0000000..ca717f8 --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitReportPanel.gd.uid @@ -0,0 +1 @@ +uid://dhcy3bq74qcl0 diff --git a/addons/gdUnit4/src/ui/parts/GdUnitReportPanel.tscn b/addons/gdUnit4/src/ui/parts/GdUnitReportPanel.tscn new file mode 100644 index 0000000..99f0f58 --- /dev/null +++ b/addons/gdUnit4/src/ui/parts/GdUnitReportPanel.tscn @@ -0,0 +1,47 @@ +[gd_scene format=3 uid="uid://dga8nhaxnyr53"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/GdUnitReportPanel.gd" id="1_w8qy0"] + +[node name="report" type="Panel" unique_id=1075065511] +clip_contents = true +custom_minimum_size = Vector2(120, 80) +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1_w8qy0") + +[node name="message" type="RichTextLabel" parent="." unique_id=1680360176] +unique_name_in_owner = true +auto_translate_mode = 2 +visible = false +layout_mode = 0 +size_flags_horizontal = 3 +size_flags_vertical = 3 +focus_mode = 2 +mouse_filter = 1 +bbcode_enabled = true +fit_content = true +autowrap_mode = 0 +selection_enabled = true + +[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=385317760] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_vertical = 3 + +[node name="report_list" type="VBoxContainer" parent="ScrollContainer" unique_id=1467163528] +unique_name_in_owner = true +clip_contents = true +custom_minimum_size = Vector2(120, 0) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 2 +theme_override_constants/separation = 2 diff --git a/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd new file mode 100644 index 0000000..b5cc837 --- /dev/null +++ b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd @@ -0,0 +1,56 @@ +@tool +class_name GdUnitInputCapture +extends Control + +signal input_completed(input_event: InputEventKey) + + +var _tween: Tween +var _input_event: InputEventKey + + +func _ready() -> void: + reset() + self_modulate = Color.WHITE + _tween = create_tween() + @warning_ignore("return_value_discarded") + _tween.set_loops() + @warning_ignore("return_value_discarded") + _tween.tween_property(%Label, "self_modulate", Color(1, 1, 1, .8), 1.0).from_current().set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_IN_OUT) + + +func reset() -> void: + _input_event = InputEventKey.new() + + +func _input(event: InputEvent) -> void: + if not is_visible_in_tree(): + return + if event is InputEventKey and event.is_pressed() and not event.is_echo(): + var _event := event as InputEventKey + match _event.keycode: + KEY_CTRL: + _input_event.ctrl_pressed = true + KEY_SHIFT: + _input_event.shift_pressed = true + KEY_ALT: + _input_event.alt_pressed = true + KEY_META: + _input_event.meta_pressed = true + _: + _input_event.keycode = _event.keycode + _apply_input_modifiers(_event) + accept_event() + + if event is InputEventKey and not event.is_pressed(): + input_completed.emit(_input_event) + hide() + + +func _apply_input_modifiers(event: InputEvent) -> void: + if event is InputEventWithModifiers: + var _event := event as InputEventWithModifiers + _input_event.meta_pressed = _event.meta_pressed or _input_event.meta_pressed + _input_event.alt_pressed = _event.alt_pressed or _input_event.alt_pressed + _input_event.shift_pressed = _event.shift_pressed or _input_event.shift_pressed + _input_event.ctrl_pressed = _event.ctrl_pressed or _input_event.ctrl_pressed diff --git a/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd.uid b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd.uid new file mode 100644 index 0000000..e5ce2a7 --- /dev/null +++ b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd.uid @@ -0,0 +1 @@ +uid://d3snyw5r4f13t diff --git a/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn new file mode 100644 index 0000000..7e62c43 --- /dev/null +++ b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn @@ -0,0 +1,36 @@ +[gd_scene load_steps=2 format=3 uid="uid://pmnkxrhglak5"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd" id="1_gki1u"] + +[node name="GdUnitInputMapper" type="Control"] +modulate = Color(0.929099, 0.929099, 0.929099, 0.936189) +top_level = true +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1_gki1u") + +[node name="Label" type="Label" parent="."] +unique_name_in_owner = true +self_modulate = Color(0.401913, 0.401913, 0.401913, 0.461723) +top_level = true +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -60.5 +offset_top = -19.5 +offset_right = 60.5 +offset_bottom = 19.5 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_font_sizes/font_size = 26 +text = "Press keys for shortcut" diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd new file mode 100644 index 0000000..14a8bd5 --- /dev/null +++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd @@ -0,0 +1,327 @@ +@tool +extends Window + +const EAXAMPLE_URL := "https://github.com/MikeSchulze/gdUnit4-examples/archive/refs/heads/master.zip" +const GdUnitTools := preload ("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const GdUnitUpdateClient = preload ("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd") + +@onready var _update_client: GdUnitUpdateClient = $GdUnitUpdateClient +@onready var _version_label: RichTextLabel = %version +@onready var _btn_install: Button = %btn_install_examples +@onready var _progress_bar: ProgressBar = %ProgressBar +@onready var _progress_text: Label = %progress_lbl +@onready var _properties_template: Control = $property_template +@onready var _properties_common: Control = % "common-content" +@onready var _properties_ui: Control = % "ui-content" +@onready var _properties_shortcuts: Control = % "shortcut-content" +@onready var _properties_report: Control = % "report-content" +@onready var _input_capture: GdUnitInputCapture = %GdUnitInputCapture +@onready var _property_error: Window = % "propertyError" +@onready var _tab_container: TabContainer = %Properties +@onready var _update_tab: Control = %Update + +var _font_size: float + + +func _ready() -> void: + set_name("GdUnitSettingsDialog") + # initialize for testing + if not Engine.is_editor_hint(): + GdUnitSettings.setup() + GdUnit4Version.init_version_label(_version_label) + _font_size = GdUnitFonts.init_fonts(_version_label) + setup_properties(_properties_common, GdUnitSettings.COMMON_SETTINGS) + setup_properties(_properties_ui, GdUnitSettings.UI_SETTINGS) + setup_properties(_properties_report, GdUnitSettings.REPORT_SETTINGS) + setup_properties(_properties_shortcuts, GdUnitSettings.SHORTCUT_SETTINGS) + check_for_update() + + +func _sort_by_key(left: GdUnitProperty, right: GdUnitProperty) -> bool: + return left.name() < right.name() + + +func setup_properties(properties_parent: Control, property_category: String) -> void: + # Do remove first potential previous added properties (could be happened when the dlg is opened at twice) + for child in properties_parent.get_children(): + properties_parent.remove_child(child) + + var category_properties := GdUnitSettings.list_settings(property_category) + # sort by key + category_properties.sort_custom(_sort_by_key) + var theme_ := Theme.new() + theme_.set_constant("h_separation", "GridContainer", 12) + var last_category := "!" + var min_size_overall := 0.0 + var labels := [] + var inputs := [] + var info_labels := [] + var grid: GridContainer = null + for p in category_properties: + var min_size_ := 0.0 + var property: GdUnitProperty = p + var current_category := property.category() + if not grid or current_category != last_category: + grid = GridContainer.new() + grid.columns = 4 + grid.theme = theme_ + + var sub_category: Control = _properties_template.get_child(3).duplicate() + var category_label: Label = sub_category.get_child(0) + category_label.text = current_category.capitalize() + sub_category.custom_minimum_size.y = _font_size + 16 + properties_parent.add_child(sub_category) + properties_parent.add_child(grid) + last_category = current_category + # property name + var label: Label = _properties_template.get_child(0).duplicate() + label.text = _to_human_readable(property.name()) + labels.append(label) + grid.add_child(label) + + # property reset btn + var reset_btn: Button = _properties_template.get_child(1).duplicate() + reset_btn.icon = _get_btn_icon("Reload") + reset_btn.disabled = property.value() == property.default() + grid.add_child(reset_btn) + + # property type specific input element + var input: Node = _create_input_element(property, reset_btn) + inputs.append(input) + grid.add_child(input) + @warning_ignore("return_value_discarded") + reset_btn.pressed.connect(_on_btn_property_reset_pressed.bind(property, input, reset_btn)) + # property help text + var info: Label = _properties_template.get_child(2).duplicate() + info.text = property.help() + info_labels.append(info) + grid.add_child(info) + if min_size_overall < min_size_: + min_size_overall = min_size_ + + for controls: Array in [labels, inputs, info_labels]: + var _size: float = controls.map(func(c: Control) -> float: return c.size.x).max() + min_size_overall += _size + for control: Control in controls: + control.custom_minimum_size.x = _size + properties_parent.custom_minimum_size.x = min_size_overall + + +func _create_input_element(property: GdUnitProperty, reset_btn: Button) -> Node: + if property.is_selectable_value(): + var options := OptionButton.new() + options.alignment = HORIZONTAL_ALIGNMENT_CENTER + for value in property.value_set(): + options.add_item(value) + options.item_selected.connect(_on_option_selected.bind(property, reset_btn)) + options.select(property.int_value()) + return options + if property.type() == TYPE_BOOL: + var check_btn := CheckButton.new() + check_btn.toggled.connect(_on_property_text_changed.bind(property, reset_btn)) + check_btn.button_pressed = property.value() + return check_btn + if property.type() in [TYPE_INT, TYPE_STRING]: + var input := LineEdit.new() + input.text_changed.connect(_on_property_text_changed.bind(property, reset_btn)) + input.set_context_menu_enabled(false) + input.set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER) + input.set_expand_to_text_length_enabled(true) + input.text = str(property.value()) + return input + if property.type() == TYPE_PACKED_INT32_ARRAY: + var key_input_button := Button.new() + var value:PackedInt32Array = property.value() + key_input_button.text = to_shortcut(value) + key_input_button.pressed.connect(_on_shortcut_change.bind(key_input_button, property, reset_btn)) + return key_input_button + return Control.new() + + +func to_shortcut(keys: PackedInt32Array) -> String: + var input_event := InputEventKey.new() + for key in keys: + match key: + KEY_CTRL: input_event.ctrl_pressed = true + KEY_SHIFT: input_event.shift_pressed = true + KEY_ALT: input_event.alt_pressed = true + KEY_META: input_event.meta_pressed = true + _: + input_event.keycode = key as Key + return input_event.as_text() + + +func to_keys(input_event: InputEventKey) -> PackedInt32Array: + var keys := PackedInt32Array() + if input_event.ctrl_pressed: + keys.append(KEY_CTRL) + if input_event.shift_pressed: + keys.append(KEY_SHIFT) + if input_event.alt_pressed: + keys.append(KEY_ALT) + if input_event.meta_pressed: + keys.append(KEY_META) + keys.append(input_event.keycode) + return keys + + +func _to_human_readable(value: String) -> String: + return value.split("/")[-1].capitalize() + + +func _get_btn_icon(p_name: String) -> Texture2D: + if not Engine.is_editor_hint(): + var placeholder := PlaceholderTexture2D.new() + placeholder.size = Vector2(8, 8) + return placeholder + return GdUnitUiTools.get_icon(p_name) + + +func _install_examples() -> void: + _init_progress(5) + update_progress("Downloading examples") + await get_tree().process_frame + var tmp_path := GdUnitFileAccess.create_temp_dir("download") + var zip_file := tmp_path + "/examples.zip" + var response: GdUnitUpdateClient.HttpResponse = await _update_client.request_zip_package(EAXAMPLE_URL, zip_file) + if response.status() != 200: + push_warning("Examples cannot be retrieved from GitHub! \n Error code: %d : %s" % [response.status(), response.response()]) + update_progress("Install examples failed! Try it later again.") + await get_tree().create_timer(3).timeout + stop_progress() + return + # extract zip to tmp + update_progress("Install examples into project") + var result := GdUnitFileAccess.extract_zip(zip_file, "res://gdUnit4-examples/") + if result.is_error(): + update_progress("Install examples failed! %s" % result.error_message()) + await get_tree().create_timer(3).timeout + stop_progress() + return + update_progress("Refresh project") + await rescan() + await reimport("res://gdUnit4-examples/") + + update_progress("Examples successfully installed") + await get_tree().create_timer(3).timeout + stop_progress() + + +func rescan() -> void: + await get_tree().process_frame + var fs := EditorInterface.get_resource_filesystem() + fs.scan_sources() + while fs.is_scanning(): + await get_tree().create_timer(1).timeout + + +func reimport(path: String) -> void: + await get_tree().process_frame + var files := DirAccess.get_files_at(path) + EditorInterface.get_resource_filesystem().reimport_files(files) + for directory in DirAccess.get_directories_at(path): + reimport(directory) + + +func check_for_update() -> void: + if not GdUnitSettings.is_update_notification_enabled(): + return + var response :GdUnitUpdateClient.HttpResponse = await _update_client.request_latest_version() + if response.status() != 200: + printerr("Latest version information cannot be retrieved from GitHub!") + printerr("Error: %s" % response.response()) + return + var latest_version := _update_client.extract_latest_version(response) + if latest_version.is_greater(GdUnit4Version.current()): + var tab_index := _tab_container.get_tab_idx_from_control(_update_tab) + _tab_container.set_tab_button_icon(tab_index, GdUnitUiTools.get_icon("Notification", Color.YELLOW)) + _tab_container.set_tab_tooltip(tab_index, "An new update is available.") + + +func _on_btn_report_bug_pressed() -> void: + @warning_ignore("return_value_discarded") + OS.shell_open("https://github.com/MikeSchulze/gdUnit4/issues/new?assignees=MikeSchulze&labels=bug&projects=projects%2F5&template=bug_report.yml&title=GD-XXX%3A+Describe+the+issue+briefly") + + +func _on_btn_request_feature_pressed() -> void: + @warning_ignore("return_value_discarded") + OS.shell_open("https://github.com/MikeSchulze/gdUnit4/issues/new?assignees=MikeSchulze&labels=enhancement&projects=&template=feature_request.md&title=") + + +func _on_btn_install_examples_pressed() -> void: + _btn_install.disabled = true + await _install_examples() + _btn_install.disabled = false + + +func _on_btn_close_pressed() -> void: + hide() + + +func _on_btn_property_reset_pressed(property: GdUnitProperty, input: Node, reset_btn: Button) -> void: + if input is CheckButton: + var is_default_pressed: bool = property.default() + (input as CheckButton).button_pressed = is_default_pressed + elif input is LineEdit: + (input as LineEdit).text = str(property.default()) + # we have to update manually for text input fields because of no change event is emited + _on_property_text_changed(property.default(), property, reset_btn) + elif input is OptionButton: + (input as OptionButton).select(0) + _on_option_selected(0, property, reset_btn) + elif input is Button: + var value: PackedInt32Array = property.default() + (input as Button).text = to_shortcut(value) + _on_property_text_changed(value, property, reset_btn) + + +func _on_property_text_changed(new_value: Variant, property: GdUnitProperty, reset_btn: Button) -> void: + property.set_value(new_value) + reset_btn.disabled = property.value() == property.default() + var error: Variant = GdUnitSettings.update_property(property) + if error: + var label: Label = _property_error.get_child(0) as Label + label.set_text(str(error)) + var control := gui_get_focus_owner() + _property_error.show() + if control != null: + _property_error.position = control.global_position + Vector2(self.position) + Vector2(40, 40) + + +func _on_option_selected(index: int, property: GdUnitProperty, reset_btn: Button) -> void: + property.set_value(index) + reset_btn.disabled = property.value() == property.default() + GdUnitSettings.update_property(property) + + +func _on_shortcut_change(input_button: Button, property: GdUnitProperty, reset_btn: Button) -> void: + _input_capture.set_custom_minimum_size(_properties_shortcuts.get_size()) + _input_capture.visible = true + _input_capture.show() + _properties_shortcuts.visible = false + set_process_input(false) + _input_capture.reset() + var input_event: InputEventKey = await _input_capture.input_completed + input_button.text = input_event.as_text() + _on_property_text_changed(to_keys(input_event), property, reset_btn) + _properties_shortcuts.visible = true + set_process_input(true) + + +func _init_progress(max_value: int) -> void: + _progress_bar.visible = true + _progress_bar.max_value = max_value + _progress_bar.value = 0 + + +func _progress() -> void: + _progress_bar.value += 1 + + +func stop_progress() -> void: + _progress_bar.visible = false + + +func update_progress(message: String) -> void: + _progress_text.text = message + _progress_bar.value += 1 diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd.uid b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd.uid new file mode 100644 index 0000000..4fd5de8 --- /dev/null +++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd.uid @@ -0,0 +1 @@ +uid://xam8iuvt0c6u diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn new file mode 100644 index 0000000..4f74431 --- /dev/null +++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn @@ -0,0 +1,349 @@ +[gd_scene load_steps=9 format=3] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd" id="2"] +[ext_resource type="Texture2D" path="res://addons/gdUnit4/src/ui/settings/logo.png" id="3_isfyl"] +[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/templates/GdUnitTestSuiteTemplatePanel.tscn" id="4"] +[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn" id="4_nf72w"] +[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn" id="5_n1jtv"] +[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn" id="5_xu3j8"] +[ext_resource type="Script" path="res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd" id="8_2ggr0"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hbbq5"] +content_margin_left = 10.0 +content_margin_right = 10.0 +bg_color = Color(0.172549, 0.113725, 0.141176, 1) +border_width_left = 4 +border_width_top = 4 +border_width_right = 4 +border_width_bottom = 4 +border_color = Color(0.87451, 0.0705882, 0.160784, 1) +border_blend = true +corner_radius_top_left = 8 +corner_radius_top_right = 8 +corner_radius_bottom_right = 8 +corner_radius_bottom_left = 8 +shadow_color = Color(0, 0, 0, 0.756863) +shadow_size = 10 +shadow_offset = Vector2(10, 10) + +[node name="GdUnitSettingsDialog" type="Window"] +disable_3d = true +gui_embed_subwindows = true +title = "GdUnit4 Settings" +initial_position = 1 +size = Vector2i(1400, 600) +visible = false +wrap_controls = true +exclusive = true +extend_to_title = true +script = ExtResource("2") + +[node name="property_template" type="Control" parent="."] +visible = false +layout_mode = 3 +anchors_preset = 0 +offset_left = 4.0 +offset_top = 4.0 +offset_right = 4.0 +offset_bottom = 4.0 +size_flags_horizontal = 0 + +[node name="Label" type="Label" parent="property_template"] +layout_mode = 1 +anchors_preset = 4 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_top = -11.5 +offset_right = 1.0 +offset_bottom = 11.5 +grow_vertical = 2 + +[node name="btn_reset" type="Button" parent="property_template"] +layout_mode = 0 +offset_right = 12.0 +offset_bottom = 40.0 +tooltip_text = "Reset to default value" +clip_text = true + +[node name="info" type="Label" parent="property_template"] +clip_contents = true +layout_mode = 1 +anchors_preset = 4 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_top = -11.5 +offset_right = 316.0 +offset_bottom = 11.5 +grow_vertical = 2 +size_flags_horizontal = 3 +max_lines_visible = 1 + +[node name="sub_category" type="Panel" parent="property_template"] +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_right = -220.0 +grow_horizontal = 2 +size_flags_horizontal = 3 + +[node name="Label" type="Label" parent="property_template/sub_category"] +layout_mode = 1 +anchors_preset = 4 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 4.0 +offset_top = -11.5 +offset_right = 5.0 +offset_bottom = 11.5 +grow_vertical = 2 +theme_override_colors/font_color = Color(0.439216, 0.45098, 1, 1) + +[node name="GdUnitUpdateClient" type="Node" parent="."] +script = ExtResource("8_2ggr0") + +[node name="Panel" type="Panel" parent="."] +use_parent_material = true +clip_contents = true +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="PanelContainer" type="PanelContainer" parent="Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="v" type="VBoxContainer" parent="Panel/PanelContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="MarginContainer" type="MarginContainer" parent="Panel/PanelContainer/v"] +use_parent_material = true +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/margin_left = 4 +theme_override_constants/margin_top = 4 +theme_override_constants/margin_right = 4 + +[node name="GridContainer" type="HBoxContainer" parent="Panel/PanelContainer/v/MarginContainer"] +use_parent_material = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="PanelContainer" type="MarginContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer"] +use_parent_material = true +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Panel" type="VBoxContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer"] +use_parent_material = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="CenterContainer" type="CenterContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/Panel"] +use_parent_material = true +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="logo" type="TextureRect" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/Panel/CenterContainer"] +custom_minimum_size = Vector2(120, 120) +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 4 +texture = ExtResource("3_isfyl") +expand_mode = 1 +stretch_mode = 5 + +[node name="CenterContainer2" type="MarginContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/Panel"] +use_parent_material = true +custom_minimum_size = Vector2(0, 30) +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="version" type="RichTextLabel" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/Panel/CenterContainer2"] +unique_name_in_owner = true +auto_translate_mode = 2 +use_parent_material = true +clip_contents = false +layout_mode = 2 +size_flags_horizontal = 3 +localize_numeral_system = false +bbcode_enabled = true +scroll_active = false +meta_underlined = false + +[node name="VBoxContainer" type="VBoxContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer"] +custom_minimum_size = Vector2(200, 0) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +alignment = 2 + +[node name="btn_report_bug" type="Button" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/VBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +tooltip_text = "Press to create a bug report" +text = "Report Bug" + +[node name="btn_request_feature" type="Button" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/VBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +tooltip_text = "Press to create a feature request" +text = "Request Feature" + +[node name="btn_install_examples" type="Button" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +tooltip_text = "Press to install the advanced test examples" +disabled = true +text = "Install Examples" + +[node name="Properties" type="TabContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +current_tab = 0 + +[node name="Common" type="ScrollContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties"] +layout_mode = 2 +metadata/_tab_index = 0 + +[node name="common-content" type="VBoxContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties/Common"] +unique_name_in_owner = true +clip_contents = true +custom_minimum_size = Vector2(1026, 0) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Hooks" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties" instance=ExtResource("4_nf72w")] +visible = false +layout_mode = 2 + +[node name="UI" type="ScrollContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties"] +visible = false +layout_mode = 2 +metadata/_tab_index = 2 + +[node name="ui-content" type="VBoxContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties/UI"] +unique_name_in_owner = true +clip_contents = true +custom_minimum_size = Vector2(741, 0) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Shortcuts" type="ScrollContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties"] +visible = false +layout_mode = 2 +metadata/_tab_index = 3 + +[node name="shortcut-content" type="VBoxContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties/Shortcuts"] +unique_name_in_owner = true +clip_contents = true +custom_minimum_size = Vector2(683, 0) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Report" type="ScrollContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties"] +visible = false +layout_mode = 2 +metadata/_tab_index = 4 + +[node name="report-content" type="VBoxContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties/Report"] +unique_name_in_owner = true +clip_contents = true +custom_minimum_size = Vector2(667, 0) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Templates" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties" instance=ExtResource("4")] +visible = false +layout_mode = 2 +metadata/_tab_index = 5 + +[node name="Update" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties" instance=ExtResource("5_n1jtv")] +unique_name_in_owner = true +visible = false +layout_mode = 2 +metadata/_tab_index = 6 + +[node name="GdUnitInputCapture" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties" instance=ExtResource("5_xu3j8")] +unique_name_in_owner = true +visible = false +modulate = Color(1.54884e-09, 1.54884e-09, 1.54884e-09, 0.1) +z_index = 1 +z_as_relative = false +layout_mode = 2 +size_flags_horizontal = 1 +size_flags_vertical = 1 + +[node name="propertyError" type="PopupPanel" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties"] +unique_name_in_owner = true +initial_position = 1 +size = Vector2i(400, 100) +theme_override_styles/panel = SubResource("StyleBoxFlat_hbbq5") + +[node name="Label" type="Label" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties/propertyError"] +offset_left = 10.0 +offset_top = 4.0 +offset_right = 390.0 +offset_bottom = 96.0 +theme_override_colors/font_color = Color(0.858824, 0, 0.109804, 1) +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="MarginContainer2" type="MarginContainer" parent="Panel/PanelContainer/v"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/margin_left = 4 +theme_override_constants/margin_right = 4 +theme_override_constants/margin_bottom = 4 + +[node name="HBoxContainer" type="HBoxContainer" parent="Panel/PanelContainer/v/MarginContainer2"] +layout_mode = 2 +size_flags_horizontal = 3 +alignment = 2 + +[node name="ProgressBar" type="ProgressBar" parent="Panel/PanelContainer/v/MarginContainer2/HBoxContainer"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="progress_lbl" type="Label" parent="Panel/PanelContainer/v/MarginContainer2/HBoxContainer/ProgressBar"] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +clip_text = true + +[node name="btn_close" type="Button" parent="Panel/PanelContainer/v/MarginContainer2/HBoxContainer"] +custom_minimum_size = Vector2(200, 0) +layout_mode = 2 +text = "Close" + +[connection signal="close_requested" from="." to="." method="_on_btn_close_pressed"] +[connection signal="pressed" from="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/VBoxContainer/btn_report_bug" to="." method="_on_btn_report_bug_pressed"] +[connection signal="pressed" from="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/VBoxContainer/btn_request_feature" to="." method="_on_btn_request_feature_pressed"] +[connection signal="pressed" from="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/VBoxContainer/btn_install_examples" to="." method="_on_btn_install_examples_pressed"] +[connection signal="pressed" from="Panel/PanelContainer/v/MarginContainer2/HBoxContainer/btn_close" to="." method="_on_btn_close_pressed"] diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd new file mode 100644 index 0000000..dacb48e --- /dev/null +++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd @@ -0,0 +1,208 @@ +@tool +extends ScrollContainer + + +@onready var _hooks_list: VBoxContainer = %hook_list +@onready var _hook_row_template: Panel = %row_template +@onready var _hook_description: RichTextLabel = %hook_description +@onready var _btn_move_up: Button = %hook_actions/btn_move_up +@onready var _btn_move_down: Button = %hook_actions/btn_move_down +@onready var _btn_delete: Button = %hook_actions/btn_delete_hook +@onready var _select_hook_dlg: FileDialog = %select_hook_dlg +@onready var _error_msg_popup: AcceptDialog = %error_msg_popup + + +var _selected_row: Control = null +var selected_style := StyleBoxFlat.new() + + +func _ready() -> void: + selected_style.set_border_color(Color.DODGER_BLUE) + selected_style.set_border_width_all(2) + selected_style.set_corner_radius_all(4) + selected_style.set_draw_center(false) + + for hook: GdUnitTestSessionHook in GdUnitTestSessionHookService.instance().enigne_hooks: + _create_hook_row(hook) + + _update_focus_relations() + + if _hooks_list.get_child_count() > 0: + _select_row(_hooks_list.get_child(0)) + + +func _update_focus_relations() -> void: + var row_count := _hooks_list.get_child_count() + for hook_index in row_count: + var current_hook: Control = _hooks_list.get_child(hook_index) + var previous_hook: Control = current_hook if hook_index == 0 else _hooks_list.get_child(hook_index - 1) + var next_hook: Control = current_hook if hook_index >= row_count - 1 else _hooks_list.get_child(hook_index + 1) + current_hook.focus_neighbor_top = previous_hook.get_path() + current_hook.set_focus_previous(previous_hook.get_path()) + current_hook.focus_neighbor_bottom = next_hook.get_path() + current_hook.set_focus_next(next_hook.get_path()) + + +func _create_hook_row(hook: GdUnitTestSessionHook) -> Control: + var is_system := _is_system_hook(hook) + var panel: Panel = _hook_row_template.duplicate() + + panel.visible = true + panel.set_meta("hook", hook) + panel.tooltip_text = "System hook - (Read-only)" if is_system else "User hook" + panel.focus_entered.connect(_select_row.bind(panel)) + panel.gui_input.connect(func(event: InputEvent) -> void: + @warning_ignore("unsafe_property_access") + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + _select_row(panel) + panel.accept_event() + ) + + # Left color indicator border + var indicator: ColorRect = panel.find_child("Indicator", true, false) + indicator.color = Color(1.0, 0.76, 0.03, 1) if is_system else Color(0.26, 0.54, 0.89, 1) + + # Hook name + var name_label: Label = panel.find_child("HookName", true, false) + name_label.text = hook.name + + # System badge + if is_system: + var badge: Control = panel.find_child("SystemBadge", true, false) + badge.visible = true + + # Enable/disable toggle + var check: CheckButton = panel.find_child("Enabled", true, false) + check.button_pressed = GdUnitTestSessionHookService.is_enabled(hook) + check.toggled.connect(func(enabled: bool) -> void: + GdUnitTestSessionHookService.instance().enable_hook(hook, enabled) + ) + + _hooks_list.add_child(panel) + return panel + + +func _select_row(row: Node) -> void: + if _selected_row == row: + return + + if _selected_row != null and is_instance_valid(_selected_row): + _selected_row.remove_theme_stylebox_override("panel") + + _selected_row = row + + if _selected_row != null: + _selected_row.grab_focus() + + _selected_row.add_theme_stylebox_override("panel", selected_style) + + _update_hook_buttons() + _update_hook_description() + + +func _update_hook_description() -> void: + if _selected_row == null: + _hook_description.text = "[i]Select a hook to view its description[/i]" + return + _hook_description.text = _get_hook(_selected_row).description + + +func _update_hook_buttons() -> void: + if _selected_row == null: + _btn_move_up.disabled = true + _btn_move_down.disabled = true + _btn_delete.disabled = true + return + + var hook := _get_hook(_selected_row) + if _is_system_hook(hook): + _btn_move_up.disabled = true + _btn_move_down.disabled = true + _btn_delete.disabled = true + return + + var idx := _selected_row.get_index() + var count := _hooks_list.get_child_count() + var prev: Control = _hooks_list.get_child(idx - 1) if idx > 0 else null + var next: Control = _hooks_list.get_child(idx + 1) if idx + 1 < count else null + + _btn_move_up.disabled = prev == null or _is_system_hook(_get_hook(prev)) + _btn_move_down.disabled = next == null + _btn_delete.disabled = false + + +static func _get_hook(row: Control) -> GdUnitTestSessionHook: + return row.get_meta("hook") + + +static func _is_system_hook(hook: GdUnitTestSessionHook) -> bool: + if hook == null: + return false + return hook.get_meta("SYSTEM_HOOK", false) + + +func _on_btn_add_hook_pressed() -> void: + _select_hook_dlg.show() + + +func _on_select_hook_dlg_file_selected(path: String) -> void: + _select_hook_dlg.set_current_path(path) + _on_select_hook_dlg_confirmed() + + +func _on_select_hook_dlg_confirmed() -> void: + _select_hook_dlg.hide() + var result := GdUnitTestSessionHookService.instance().load_hook(_select_hook_dlg.get_current_path()) + if result.is_error(): + _error_msg_popup.dialog_text = result.error_message() + _error_msg_popup.show() + return + + var hook: GdUnitTestSessionHook = result.value() + result = GdUnitTestSessionHookService.instance().register(hook) + if result.is_error(): + _error_msg_popup.dialog_text = result.error_message() + _error_msg_popup.show() + return + + var row := _create_hook_row(hook) + _update_focus_relations() + _select_row(row) + + +func _on_btn_delete_hook_pressed() -> void: + if _selected_row == null: + return + GdUnitTestSessionHookService.instance().unregister(_get_hook(_selected_row)) + + _hooks_list.remove_child(_selected_row) + _selected_row.queue_free() + + _update_focus_relations() + _select_row(_hooks_list.find_next_valid_focus()) + + +func _on_btn_move_up_pressed() -> void: + if _selected_row == null: + return + var idx := _selected_row.get_index() - 1 + if idx <= 0: + return + var prev: Control = _hooks_list.get_child(idx) + _hooks_list.move_child(_selected_row, idx) + GdUnitTestSessionHookService.instance().move_before(_get_hook(_selected_row), _get_hook(prev)) + _update_focus_relations() + _update_hook_buttons() + + +func _on_btn_move_down_pressed() -> void: + if _selected_row == null: + return + var idx := _selected_row.get_index() + 1 + if idx >= _hooks_list.get_child_count(): + return + var next: Control = _hooks_list.get_child(idx) + _hooks_list.move_child(_selected_row, idx) + GdUnitTestSessionHookService.instance().move_after(_get_hook(_selected_row), _get_hook(next)) + _update_focus_relations() + _update_hook_buttons() diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd.uid b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd.uid new file mode 100644 index 0000000..455cc4e --- /dev/null +++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd.uid @@ -0,0 +1 @@ +uid://dqwl7gtce3l53 diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn new file mode 100644 index 0000000..3d241c2 --- /dev/null +++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn @@ -0,0 +1,227 @@ +[gd_scene format=3 uid="uid://41l7a46fol5m"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd" id="1_8yffn"] + +[sub_resource type="Image" id="Image_h5sr5"] +data = { +"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 234, 234, 234, 12, 224, 224, 224, 255, 224, 224, 224, 255, 234, 234, 234, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 1, 225, 225, 225, 174, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 173, 255, 255, 255, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 123, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 122, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 74, 224, 224, 224, 253, 224, 224, 224, 253, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 253, 224, 224, 224, 253, 224, 224, 224, 74, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 228, 228, 228, 37, 224, 224, 224, 240, 224, 224, 224, 255, 224, 224, 224, 122, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 123, 224, 224, 224, 255, 224, 224, 224, 239, 228, 228, 228, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 200, 224, 224, 224, 255, 224, 224, 224, 172, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 1, 224, 224, 224, 173, 224, 224, 224, 255, 225, 225, 225, 199, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 180, 224, 224, 224, 193, 234, 234, 234, 12, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 234, 234, 234, 12, 224, 224, 224, 193, 224, 224, 224, 179, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 180, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 181, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 180, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), +"format": "RGBA8", +"height": 16, +"mipmaps": false, +"width": 16 +} + +[sub_resource type="ImageTexture" id="ImageTexture_77fm0"] +image = SubResource("Image_h5sr5") + +[sub_resource type="Image" id="Image_77fm0"] +data = { +"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 181, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 180, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 181, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 181, 224, 224, 224, 193, 234, 234, 234, 12, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 234, 234, 234, 12, 224, 224, 224, 193, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 200, 224, 224, 224, 255, 224, 224, 224, 173, 255, 255, 255, 1, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 1, 225, 225, 225, 174, 224, 224, 224, 255, 225, 225, 225, 199, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 228, 228, 228, 37, 224, 224, 224, 239, 224, 224, 224, 255, 224, 224, 224, 122, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 123, 224, 224, 224, 255, 224, 224, 224, 239, 227, 227, 227, 36, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 74, 224, 224, 224, 253, 224, 224, 224, 253, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 253, 224, 224, 224, 253, 224, 224, 224, 73, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 123, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 122, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 1, 224, 224, 224, 173, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 172, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 234, 234, 234, 12, 224, 224, 224, 255, 224, 224, 224, 255, 234, 234, 234, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), +"format": "RGBA8", +"height": 16, +"mipmaps": false, +"width": 16 +} + +[sub_resource type="ImageTexture" id="ImageTexture_rewru"] +image = SubResource("Image_77fm0") + +[sub_resource type="Image" id="Image_kppp6"] +data = { +"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), +"format": "RGBA8", +"height": 16, +"mipmaps": false, +"width": 16 +} + +[sub_resource type="ImageTexture" id="ImageTexture_manhx"] +image = SubResource("Image_kppp6") + +[sub_resource type="Image" id="Image_rewru"] +data = { +"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 227, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 225, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 73, 224, 224, 224, 226, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 225, 226, 226, 226, 70, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), +"format": "RGBA8", +"height": 16, +"mipmaps": false, +"width": 16 +} + +[sub_resource type="ImageTexture" id="ImageTexture_4h4u1"] +image = SubResource("Image_rewru") + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_h5sr5"] +content_margin_left = 4.0 +content_margin_top = 2.0 +content_margin_right = 4.0 +content_margin_bottom = 2.0 +bg_color = Color(1, 0.85490197, 0.011764706, 1) +border_width_left = 2 +border_width_top = 4 +border_width_right = 2 +border_width_bottom = 4 +border_color = Color(0.8, 0.8, 0.8, 0) +corner_radius_top_left = 4 +corner_radius_top_right = 4 +corner_radius_bottom_right = 4 +corner_radius_bottom_left = 4 + +[node name="Hooks" type="ScrollContainer" unique_id=659565185] +custom_minimum_size = Vector2(400, 300) +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1_8yffn") +metadata/_tab_index = 1 + +[node name="HBoxContainer" type="HBoxContainer" parent="." unique_id=1746177026] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="hooks_content" type="VBoxContainer" parent="HBoxContainer" unique_id=1756623882] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Panel" type="Panel" parent="HBoxContainer/hooks_content" unique_id=1467652907] +modulate = Color(0.827451, 0.827451, 0.827451, 1) +layout_mode = 2 +size_flags_vertical = 3 + +[node name="hook_scroll" type="ScrollContainer" parent="HBoxContainer/hooks_content/Panel" unique_id=1296435931] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_vertical = 3 + +[node name="hook_list" type="VBoxContainer" parent="HBoxContainer/hooks_content/Panel/hook_scroll" unique_id=1329270012] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="hook_description" type="RichTextLabel" parent="HBoxContainer/hooks_content" unique_id=1554657128] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 120) +layout_mode = 2 +size_flags_vertical = 2 +bbcode_enabled = true +text = "" +scroll_active = false + +[node name="hook_actions" type="VBoxContainer" parent="HBoxContainer" unique_id=1441205618] +unique_name_in_owner = true +custom_minimum_size = Vector2(80, 0) +layout_mode = 2 +size_flags_horizontal = 8 +theme_override_constants/separation = 5 + +[node name="btn_move_up" type="Button" parent="HBoxContainer/hook_actions" unique_id=1909528536] +layout_mode = 2 +tooltip_text = "Move hook up in priority" +disabled = true +icon = SubResource("ImageTexture_77fm0") +icon_alignment = 1 + +[node name="btn_move_down" type="Button" parent="HBoxContainer/hook_actions" unique_id=1385113071] +layout_mode = 2 +tooltip_text = "Move hook down in priority" +disabled = true +icon = SubResource("ImageTexture_rewru") +icon_alignment = 1 + +[node name="btn_add_hook" type="Button" parent="HBoxContainer/hook_actions" unique_id=338115845] +layout_mode = 2 +tooltip_text = "Add new hook" +icon = SubResource("ImageTexture_manhx") +icon_alignment = 1 + +[node name="btn_delete_hook" type="Button" parent="HBoxContainer/hook_actions" unique_id=642037387] +layout_mode = 2 +tooltip_text = "Delete selected hook" +disabled = true +icon = SubResource("ImageTexture_4h4u1") +icon_alignment = 1 + +[node name="select_hook_dlg" type="FileDialog" parent="." unique_id=2043681690] +unique_name_in_owner = true +disable_3d = true +title = "Open a File" +initial_position = 3 +current_screen = 0 +ok_button_text = "Open" +file_mode = 0 +filters = PackedStringArray("*.gd") + +[node name="error_msg_popup" type="AcceptDialog" parent="." unique_id=1430197292] +unique_name_in_owner = true +initial_position = 3 +current_screen = 0 + +[node name="row_template" type="Panel" parent="." unique_id=2068077019] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(0, 26) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="row_template" unique_id=421161803] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Spacer1" type="MarginContainer" parent="row_template/HBoxContainer" unique_id=1388548361] +clip_contents = true +custom_minimum_size = Vector2(12, 0) +layout_mode = 2 +size_flags_horizontal = 0 +theme_override_constants/margin_left = 3 +theme_override_constants/margin_top = 3 +theme_override_constants/margin_bottom = 3 + +[node name="Indicator" type="ColorRect" parent="row_template/HBoxContainer/Spacer1" unique_id=695255812] +custom_minimum_size = Vector2(4, 0) +layout_mode = 2 +color = Color(1, 1, 0, 1) + +[node name="HookName" type="Label" parent="row_template/HBoxContainer" unique_id=167022167] +layout_mode = 2 +size_flags_horizontal = 2 +text = "" + +[node name="SystemBadge" type="PanelContainer" parent="row_template/HBoxContainer" unique_id=1220764160] +layout_mode = 2 +size_flags_vertical = 4 +theme_override_styles/panel = SubResource("StyleBoxFlat_h5sr5") + +[node name="Label" type="Label" parent="row_template/HBoxContainer/SystemBadge" unique_id=1972968522] +layout_mode = 2 +theme_override_colors/font_color = Color(0.008202022, 0.008202025, 0.008202025, 1) +theme_override_font_sizes/font_size = 10 +text = "SYSTEM" + +[node name="Enabled" type="CheckButton" parent="row_template/HBoxContainer" unique_id=365063095] +layout_mode = 2 +size_flags_vertical = 4 +tooltip_text = "Enable/Disable the Hook" +focus_mode = 0 + +[connection signal="pressed" from="HBoxContainer/hook_actions/btn_move_up" to="." method="_on_btn_move_up_pressed"] +[connection signal="pressed" from="HBoxContainer/hook_actions/btn_move_down" to="." method="_on_btn_move_down_pressed"] +[connection signal="pressed" from="HBoxContainer/hook_actions/btn_add_hook" to="." method="_on_btn_add_hook_pressed"] +[connection signal="pressed" from="HBoxContainer/hook_actions/btn_delete_hook" to="." method="_on_btn_delete_hook_pressed"] +[connection signal="confirmed" from="select_hook_dlg" to="." method="_on_select_hook_dlg_confirmed"] +[connection signal="file_selected" from="select_hook_dlg" to="." method="_on_select_hook_dlg_file_selected"] diff --git a/addons/gdUnit4/src/ui/settings/logo.png b/addons/gdUnit4/src/ui/settings/logo.png new file mode 100644 index 0000000..12de79f Binary files /dev/null and b/addons/gdUnit4/src/ui/settings/logo.png differ diff --git a/addons/gdUnit4/src/ui/settings/logo.png.import b/addons/gdUnit4/src/ui/settings/logo.png.import new file mode 100644 index 0000000..5b10af7 --- /dev/null +++ b/addons/gdUnit4/src/ui/settings/logo.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bsfwelcotvtg0" +path="res://.godot/imported/logo.png-deda0e4ba02a0b9e4e4a830029a5817f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gdUnit4/src/ui/settings/logo.png" +dest_files=["res://.godot/imported/logo.png-deda0e4ba02a0b9e4e4a830029a5817f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/addons/gdUnit4/src/ui/templates/GdUnitTestSuiteTemplatePanel.gd b/addons/gdUnit4/src/ui/templates/GdUnitTestSuiteTemplatePanel.gd new file mode 100644 index 0000000..be5c352 --- /dev/null +++ b/addons/gdUnit4/src/ui/templates/GdUnitTestSuiteTemplatePanel.gd @@ -0,0 +1,129 @@ +@tool +extends MarginContainer + +@onready var _template_editor :CodeEdit = $VBoxContainer/EdiorLayout/Editor +@onready var _tags_editor :CodeEdit = $Tags/MarginContainer/TextEdit +@onready var _title_bar :Panel = $VBoxContainer/sub_category +@onready var _save_button :Button = $VBoxContainer/Panel/HBoxContainer/Save +@onready var _selected_type :OptionButton = $VBoxContainer/EdiorLayout/Editor/MarginContainer/HBoxContainer/SelectType +@onready var _show_tags :PopupPanel = $Tags + + +var gd_key_words :PackedStringArray = ["extends", "class_name", "const", "var", "onready", "func", "void", "pass"] +var gdunit_key_words :PackedStringArray = ["GdUnitTestSuite", "before", "after", "before_test", "after_test"] +var _selected_template :int + + +func _ready() -> void: + setup_editor_colors() + setup_fonts() + setup_supported_types() + load_template(GdUnitTestSuiteTemplate.TEMPLATE_ID_GD) + setup_tags_help() + + +func _notification(what :int) -> void: + if what == EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED: + setup_fonts() + + +func setup_editor_colors() -> void: + if not Engine.is_editor_hint(): + return + + var background_color := get_editor_color("text_editor/theme/highlighting/background_color", Color(0.1155, 0.132, 0.1595, 1)) + var text_color := get_editor_color("text_editor/theme/highlighting/text_color", Color(0.8025, 0.81, 0.8225, 1)) + var selection_color := get_editor_color("text_editor/theme/highlighting/selection_color", Color(0.44, 0.73, 0.98, 0.4)) + + for e :CodeEdit in [_template_editor, _tags_editor]: + var editor :CodeEdit = e + editor.add_theme_color_override("background_color", background_color) + editor.add_theme_color_override("font_color", text_color) + editor.add_theme_color_override("font_readonly_color", text_color) + editor.add_theme_color_override("font_selected_color", selection_color) + setup_highlighter(editor) + + +func setup_highlighter(editor :CodeEdit) -> void: + var highlighter := CodeHighlighter.new() + editor.set_syntax_highlighter(highlighter) + var number_color := get_editor_color("text_editor/theme/highlighting/number_color", Color(0.63, 1, 0.88, 1)) + var symbol_color := get_editor_color("text_editor/theme/highlighting/symbol_color", Color(0.67, 0.79, 1, 1)) + var function_color := get_editor_color("text_editor/theme/highlighting/function_color", Color(0.34, 0.7, 1, 1)) + var member_variable_color := get_editor_color("text_editor/theme/highlighting/member_variable_color", Color(0.736, 0.88, 1, 1)) + var comment_color := get_editor_color("text_editor/theme/highlighting/comment_color", Color(0.8025, 0.81, 0.8225, 0.5)) + var keyword_color := get_editor_color("text_editor/theme/highlighting/keyword_color", Color(1, 0.44, 0.52, 1)) + var base_type_color := get_editor_color("text_editor/theme/highlighting/base_type_color", Color(0.26, 1, 0.76, 1)) + var annotation_color := get_editor_color("text_editor/theme/highlighting/gdscript/annotation_color", Color(1, 0.7, 0.45, 1)) + + highlighter.clear_color_regions() + highlighter.clear_keyword_colors() + highlighter.add_color_region("#", "", comment_color, true) + highlighter.add_color_region("${", "}", Color.YELLOW) + highlighter.add_color_region("'", "'", Color.YELLOW) + highlighter.add_color_region("\"", "\"", Color.YELLOW) + highlighter.number_color = number_color + highlighter.symbol_color = symbol_color + highlighter.function_color = function_color + highlighter.member_variable_color = member_variable_color + highlighter.add_keyword_color("@", annotation_color) + highlighter.add_keyword_color("warning_ignore", annotation_color) + for word in gd_key_words: + highlighter.add_keyword_color(word, keyword_color) + for word in gdunit_key_words: + highlighter.add_keyword_color(word, base_type_color) + + +## Using this function to avoid null references to colors on inital Godot installations. +## For more details show https://github.com/MikeSchulze/gdUnit4/issues/533 +func get_editor_color(property_name: String, default: Color) -> Color: + var settings := EditorInterface.get_editor_settings() + return settings.get_setting(property_name) if settings.has_setting(property_name) else default + + +func setup_fonts() -> void: + if _template_editor: + @warning_ignore("return_value_discarded") + GdUnitFonts.init_fonts(_template_editor) + var font_size := GdUnitFonts.init_fonts(_tags_editor) + _title_bar.size.y = font_size + 16 + _title_bar.custom_minimum_size.y = font_size + 16 + + +func setup_supported_types() -> void: + _selected_type.clear() + _selected_type.add_item("GD - GDScript", GdUnitTestSuiteTemplate.TEMPLATE_ID_GD) + _selected_type.add_item("C# - CSharpScript", GdUnitTestSuiteTemplate.TEMPLATE_ID_CS) + + +func setup_tags_help() -> void: + _tags_editor.set_text(GdUnitTestSuiteTemplate.load_tags(_selected_template)) + + +func load_template(template_id :int) -> void: + _selected_template = template_id + _template_editor.set_text(GdUnitTestSuiteTemplate.load_template(template_id)) + + +func _on_Restore_pressed() -> void: + _template_editor.set_text(GdUnitTestSuiteTemplate.default_template(_selected_template)) + GdUnitTestSuiteTemplate.reset_to_default(_selected_template) + _save_button.disabled = true + + +func _on_Save_pressed() -> void: + GdUnitTestSuiteTemplate.save_template(_selected_template, _template_editor.get_text()) + _save_button.disabled = true + + +func _on_Tags_pressed() -> void: + _show_tags.popup_centered_ratio(.5) + + +func _on_Editor_text_changed() -> void: + _save_button.disabled = false + + +func _on_SelectType_item_selected(index :int) -> void: + load_template(_selected_type.get_item_id(index)) + setup_tags_help() diff --git a/addons/gdUnit4/src/ui/templates/GdUnitTestSuiteTemplatePanel.gd.uid b/addons/gdUnit4/src/ui/templates/GdUnitTestSuiteTemplatePanel.gd.uid new file mode 100644 index 0000000..4f2104b --- /dev/null +++ b/addons/gdUnit4/src/ui/templates/GdUnitTestSuiteTemplatePanel.gd.uid @@ -0,0 +1 @@ +uid://c0ovdps4fpqqj diff --git a/addons/gdUnit4/src/ui/templates/GdUnitTestSuiteTemplatePanel.tscn b/addons/gdUnit4/src/ui/templates/GdUnitTestSuiteTemplatePanel.tscn new file mode 100644 index 0000000..c90eba1 --- /dev/null +++ b/addons/gdUnit4/src/ui/templates/GdUnitTestSuiteTemplatePanel.tscn @@ -0,0 +1,127 @@ +[gd_scene format=3 uid="uid://dte0m2endcgtu"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/templates/GdUnitTestSuiteTemplatePanel.gd" id="1"] + +[node name="TestSuiteTemplate" type="MarginContainer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="sub_category" type="Panel" parent="VBoxContainer"] +custom_minimum_size = Vector2(0, 30) +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Label" type="Label" parent="VBoxContainer/sub_category"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 4.0 +offset_right = 4.0 +offset_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +text = "Test Suite Template +" + +[node name="EdiorLayout" type="VBoxContainer" parent="VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Editor" type="CodeEdit" parent="VBoxContainer/EdiorLayout"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="MarginContainer" type="MarginContainer" parent="VBoxContainer/EdiorLayout/Editor"] +layout_mode = 1 +anchors_preset = 12 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = -31.0 +grow_horizontal = 2 +grow_vertical = 0 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/EdiorLayout/Editor/MarginContainer"] +layout_mode = 2 +size_flags_vertical = 8 +alignment = 2 + +[node name="Tags" type="Button" parent="VBoxContainer/EdiorLayout/Editor/MarginContainer/HBoxContainer"] +layout_mode = 2 +tooltip_text = "Shows supported tags." +text = "Supported Tags" + +[node name="SelectType" type="OptionButton" parent="VBoxContainer/EdiorLayout/Editor/MarginContainer/HBoxContainer"] +layout_mode = 2 +tooltip_text = "Select the script type specific template." +item_count = 2 +selected = 0 +popup/item_0/text = "GD - GDScript" +popup/item_0/id = 1000 +popup/item_1/text = "C# - CSharpScript" +popup/item_1/id = 2000 + +[node name="Panel" type="MarginContainer" parent="VBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/Panel"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +alignment = 2 + +[node name="Restore" type="Button" parent="VBoxContainer/Panel/HBoxContainer"] +layout_mode = 2 +text = "Restore" + +[node name="Save" type="Button" parent="VBoxContainer/Panel/HBoxContainer"] +layout_mode = 2 +disabled = true +text = "Save" + +[node name="Tags" type="PopupPanel" parent="."] +size = Vector2i(300, 100) +unresizable = false +content_scale_aspect = 4 + +[node name="MarginContainer" type="MarginContainer" parent="Tags"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 4.0 +offset_top = 4.0 +offset_right = -856.0 +offset_bottom = -552.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="TextEdit" type="CodeEdit" parent="Tags/MarginContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +editable = false +context_menu_enabled = false +shortcut_keys_enabled = false +virtual_keyboard_enabled = false + +[connection signal="text_changed" from="VBoxContainer/EdiorLayout/Editor" to="." method="_on_Editor_text_changed"] +[connection signal="pressed" from="VBoxContainer/EdiorLayout/Editor/MarginContainer/HBoxContainer/Tags" to="." method="_on_Tags_pressed"] +[connection signal="item_selected" from="VBoxContainer/EdiorLayout/Editor/MarginContainer/HBoxContainer/SelectType" to="." method="_on_SelectType_item_selected"] +[connection signal="pressed" from="VBoxContainer/Panel/HBoxContainer/Restore" to="." method="_on_Restore_pressed"] +[connection signal="pressed" from="VBoxContainer/Panel/HBoxContainer/Save" to="." method="_on_Save_pressed"] diff --git a/addons/gdUnit4/src/ui/utils/GdUnitEditorColorTheme.gd b/addons/gdUnit4/src/ui/utils/GdUnitEditorColorTheme.gd new file mode 100644 index 0000000..94181d0 --- /dev/null +++ b/addons/gdUnit4/src/ui/utils/GdUnitEditorColorTheme.gd @@ -0,0 +1,61 @@ +## Provides editor theme colors sourced from [EditorSettings]. +## Add this node to the scene tree so it auto-refreshes colors when the +## editor theme changes ([constant EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED]). +@tool +class_name GdUnitEditorColorTheme +extends Node + + +# Literal value of EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED. +# The editor classes must not be referenced by name anywhere in this script: +# they do not exist in template builds (exported games, CLI runners), where a +# direct reference fails to PARSE this script and every script depending on +# it - the runtime Engine.is_editor_hint() guard never gets a chance. +const _NOTIFICATION_EDITOR_SETTINGS_CHANGED := 10000 + +static var text_color := Color.WEB_GRAY +static var folder_color := Color.LIGHT_SKY_BLUE +static var function_definition_color := Color.ANTIQUE_WHITE +static var engine_type_color := Color.ANTIQUE_WHITE +static var value_color := Color.DODGER_BLUE + +# test state colors +static var state_initial := Color.LIGHT_GRAY +static var state_success := Color.WEB_GREEN +static var state_warning := Color.DARK_GOLDENROD +static var state_flaky := Color.GREEN_YELLOW +static var state_failure := Color.ORANGE_RED +static var state_error := Color.DARK_RED +static var state_skipped := Color.WEB_GRAY +static var state_orphan := Color.DARK_GOLDENROD + + +func _ready() -> void: + setup() + + +func _notification(what: int) -> void: + if what == _NOTIFICATION_EDITOR_SETTINGS_CHANGED: + setup() + + +static func setup() -> void: + if not Engine.is_editor_hint() or not Engine.has_singleton("EditorInterface"): + return + # Resolved via the singleton registry instead of naming EditorInterface - + # see the parse-time note above; on template builds the defaults apply. + var editor_interface: Object = Engine.get_singleton("EditorInterface") + var settings: Object = editor_interface.call("get_editor_settings") + text_color = settings.call("get_setting", "text_editor/theme/highlighting/text_color") + folder_color = settings.call("get_setting", "text_editor/theme/highlighting/member_variable_color") + function_definition_color = settings.call("get_setting", "text_editor/theme/highlighting/gdscript/function_definition_color") + engine_type_color = settings.call("get_setting", "text_editor/theme/highlighting/engine_type_color") + value_color = settings.call("get_setting", "text_editor/theme/highlighting/function_color") + # init test state colors + state_initial = text_color + state_success = settings.call("get_setting", "editors/animation/onion_layers_future_color") + state_warning = settings.call("get_setting", "text_editor/theme/highlighting/comment_markers/warning_color") + state_flaky = settings.call("get_setting", "text_editor/theme/highlighting/gdscript/node_reference_color") + state_failure = settings.call("get_setting", "text_editor/theme/highlighting/comment_markers/critical_color") + state_error = settings.call("get_setting", "editors/2d/smart_snapping_line_color") + state_orphan = settings.call("get_setting", "text_editor/theme/highlighting/string_placeholder_color") diff --git a/addons/gdUnit4/src/ui/utils/GdUnitEditorColorTheme.gd.uid b/addons/gdUnit4/src/ui/utils/GdUnitEditorColorTheme.gd.uid new file mode 100644 index 0000000..033adb6 --- /dev/null +++ b/addons/gdUnit4/src/ui/utils/GdUnitEditorColorTheme.gd.uid @@ -0,0 +1 @@ +uid://ct34lrbm46ud5 diff --git a/addons/gdUnit4/src/update/GdMarkDownReader.gd b/addons/gdUnit4/src/update/GdMarkDownReader.gd new file mode 100644 index 0000000..dab19e4 --- /dev/null +++ b/addons/gdUnit4/src/update/GdMarkDownReader.gd @@ -0,0 +1,405 @@ +@tool +extends RefCounted + +const GdUnitUpdateClient = preload("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd") + +const FONT_H1 := 22 +const FONT_H2 := 20 +const FONT_H3 := 18 +const FONT_H4 := 16 +const FONT_H5 := 14 +const FONT_H6 := 12 + +const HORIZONTAL_RULE := "[img=4000x2]res://addons/gdUnit4/src/update/assets/horizontal-line2.png[/img]" +const HEADER_RULE := "[font_size=%d]$1[/font_size]" +const HEADER_CENTERED_RULE := "[font_size=%d][center]$1[/center][/font_size]" + +const image_download_folder := "res://addons/gdUnit4/tmp-update/" + +const exclude_font_size := "\b(?!(?:(font_size))\b)" + +var md_replace_patterns := [ + # comments + [regex("(?m)^\\n?\\s*\\s*\\n?"), ""], + + # horizontal rules + [regex("(?m)^[ ]{0,3}---$"), HORIZONTAL_RULE], + [regex("(?m)^[ ]{0,3}___$"), HORIZONTAL_RULE], + [regex("(?m)^[ ]{0,3}\\*\\*\\*$"), HORIZONTAL_RULE], + + # headers + [regex("(?m)^###### (.*)"), HEADER_RULE % FONT_H6], + [regex("(?m)^##### (.*)"), HEADER_RULE % FONT_H5], + [regex("(?m)^#### (.*)"), HEADER_RULE % FONT_H4], + [regex("(?m)^### (.*)"), HEADER_RULE % FONT_H3], + [regex("(?m)^## (.*)"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H2], + [regex("(?m)^# (.*)"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H1], + [regex("(?m)^(.+)=={2,}$"), HEADER_RULE % FONT_H1], + [regex("(?m)^(.+)--{2,}$"), HEADER_RULE % FONT_H2], + # html headers + [regex("

((.*?\\R?)+)<\\/h1>"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H1], + [regex("((.*?\\R?)+)<\\/h1>"), (HEADER_CENTERED_RULE + HORIZONTAL_RULE) % FONT_H1], + [regex("

((.*?\\R?)+)<\\/h2>"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H2], + [regex("((.*?\\R?)+)<\\/h2>"), (HEADER_CENTERED_RULE + HORIZONTAL_RULE) % FONT_H1], + [regex("

((.*?\\R?)+)<\\/h3>"), HEADER_RULE % FONT_H3], + [regex("((.*?\\R?)+)<\\/h3>"), HEADER_CENTERED_RULE % FONT_H3], + [regex("

((.*?\\R?)+)<\\/h4>"), HEADER_RULE % FONT_H4], + [regex("((.*?\\R?)+)<\\/h4>"), HEADER_CENTERED_RULE % FONT_H4], + [regex("
((.*?\\R?)+)<\\/h5>"), HEADER_RULE % FONT_H5], + [regex("((.*?\\R?)+)<\\/h5>"), HEADER_CENTERED_RULE % FONT_H5], + [regex("
((.*?\\R?)+)<\\/h6>"), HEADER_RULE % FONT_H6], + [regex("((.*?\\R?)+)<\\/h6>"), HEADER_CENTERED_RULE % FONT_H6], + + # asterics + #[regex("(\\*)"), "xxx$1xxx"], + + # extract/compile image references + [regex("!\\[(.*?)\\]\\[(.*?)\\]"), process_image_references], + # extract images with path and optional tool tip + [regex("!\\[(.*?)\\]\\((.*?)(( )+(.*?))?\\)"), process_image], + + # links + [regex("([!]|)\\[(.+)\\]\\(([^ ]+?)\\)"), "[url={\"url\":\"$3\"}]$2[/url]"], + # links with tool tip + [regex("([!]|)\\[(.+)\\]\\(([^ ]+?)( \"(.+)\")?\\)"), "[url={\"url\":\"$3\", \"tool_tip\":\"$5\"}]$2[/url]"], + # links to github, as shorted link + [regex("(https://github.*/?/(\\S+))"), '[url={"url":"$1", "tool_tip":"$1"}]#$2[/url]'], + + # embeded text + [regex("(?m)^[ ]{0,3}>(.*?)$"), "[img=50x14]res://addons/gdUnit4/src/update/assets/embedded.png[/img][i]$1[/i]"], + + # italic + bold font + [regex("[_]{3}(.*?)[_]{3}"), "[i][b]$1[/b][/i]"], + [regex("[\\*]{3}(.*?)[\\*]{3}"), "[i][b]$1[/b][/i]"], + # bold font + [regex("(.*?)<\\/b>"), "[b]$1[/b]"], + [regex("[_]{2}(.*?)[_]{2}"), "[b]$1[/b]"], + [regex("[\\*]{2}(.*?)[\\*]{2}"), "[b]$1[/b]"], + # italic font + [regex("(.*?)<\\/i>"), "[i]$1[/i]"], + [regex(exclude_font_size+"_(.*?)_"), "[i]$1[/i]"], + [regex("\\*(.*?)\\*"), "[i]$1[/i]"], + + # strikethrough font + [regex("(.*?)"), "[s]$1[/s]"], + [regex("~~(.*?)~~"), "[s]$1[/s]"], + [regex("~(.*?)~"), "[s]$1[/s]"], + + # handling lists + # using an image for dots + [regex("(?m)^[ ]{0,1}[*\\-+] (.*)$"), list_replace(0)], + [regex("(?m)^[ ]{2,3}[*\\-+] (.*)$"), list_replace(1)], + [regex("(?m)^[ ]{4,5}[*\\-+] (.*)$"), list_replace(2)], + [regex("(?m)^[ ]{6,7}[*\\-+] (.*)$"), list_replace(3)], + [regex("(?m)^[ ]{8,9}[*\\-+] (.*)$"), list_replace(4)], + + # code + [regex("``([\\s\\S]*?)``"), code_block("$1")], + [regex("`([\\s\\S]*?)`{1,2}"), code_block("$1")], +] + +var code_block_patterns := [ + # code blocks, code blocks looks not like code blocks in richtext + [regex("```(javascript|python|shell|gdscript|gd)([\\s\\S]*?\n)```"), code_block("$2", true)], +] + +var _img_replace_regex := RegEx.new() +var _image_urls := PackedStringArray() +var _on_table_tag := false +var _client: GdUnitUpdateClient + + +static func regex(pattern: String) -> RegEx: + var regex_ := RegEx.new() + var err := regex_.compile(pattern) + if err != OK: + push_error("error '%s' checked pattern '%s'" % [err, pattern]) + return null + return regex_ + + +func _init() -> void: + @warning_ignore("return_value_discarded") + _img_replace_regex.compile("\\[img\\]((.*?))\\[/img\\]") + + +func set_http_client(client: GdUnitUpdateClient) -> void: + _client = client + + +@warning_ignore("return_value_discarded") +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + # finally remove_at the downloaded images + for image in _image_urls: + DirAccess.remove_absolute(image) + DirAccess.remove_absolute(image + ".import") + + +func list_replace(indent: int) -> String: + var replace_pattern := "[img=12x12]res://addons/gdUnit4/src/update/assets/dot2.png[/img]" if indent %2 else "[img=12x12]res://addons/gdUnit4/src/update/assets/dot1.png[/img]" + replace_pattern += " $1" + + for index in indent: + replace_pattern = replace_pattern.insert(0, " ") + return replace_pattern + + +func code_block(replace: String, border: bool = false) -> String: + if border: + return """ + [img=1400x14]res://addons/gdUnit4/src/update/assets/border_top.png[/img] + [indent][color=GRAY][font_size=16]%s[/font_size][/color][/indent] + [img=1400x14]res://addons/gdUnit4/src/update/assets/border_bottom.png[/img] + """.dedent() % replace + return "[code][bgcolor=DARK_SLATE_GRAY][color=GRAY][font_size=16]%s[/font_size][/color][/bgcolor][/code]" % replace + + +func convert_text(input: String) -> String: + input = process_tables(input) + + for pattern: Array in md_replace_patterns: + var regex_: RegEx = pattern[0] + var bb_replace: Variant = pattern[1] + if bb_replace is Callable: + @warning_ignore("unsafe_method_access") + input = await bb_replace.call(regex_, input) + else: + @warning_ignore("unsafe_cast") + input = regex_.sub(input, bb_replace as String, true) + return input + + +func convert_code_block(input: String) -> String: + for pattern: Array in code_block_patterns: + var regex_: RegEx = pattern[0] + var bb_replace: Variant = pattern[1] + if bb_replace is Callable: + @warning_ignore("unsafe_method_access") + input = await bb_replace.call(regex_, input) + else: + @warning_ignore("unsafe_cast") + input = regex_.sub(input, bb_replace as String, true) + return input + + +func to_bbcode(input: String) -> String: + var re := regex("(?m)```[\\s\\S]*?```") + var current_pos := 0 + var as_bbcode := "" + + # we split by code blocks to handle this blocks customized + for result in re.search_all(input): + # Add text before code block + if result.get_start() > current_pos: + as_bbcode += await convert_text(input.substr(current_pos, result.get_start() - current_pos)) + # Add code block + as_bbcode += await convert_code_block(result.get_string()) + current_pos = result.get_end() + + # Add remaining text after last code block + if current_pos < input.length(): + as_bbcode += await convert_text(input.substr(current_pos)) + return as_bbcode + + +func process_tables(input: String) -> String: + var bbcode := PackedStringArray() + var lines: Array[String] = Array(input.split("\n") as Array, TYPE_STRING, "", null) + while not lines.is_empty(): + if is_table(lines[0]): + bbcode.append_array(parse_table(lines)) + continue + @warning_ignore("return_value_discarded", "unsafe_cast") + bbcode.append(lines.pop_front() as String) + return "\n".join(bbcode) + + +class GdUnitMDReaderTable: + var _columns: int + var _rows: Array[Row] = [] + + class Row: + var _cells := PackedStringArray() + + + func _init(cells: PackedStringArray, columns: int) -> void: + _cells = cells + for i in range(_cells.size(), columns): + @warning_ignore("return_value_discarded") + _cells.append("") + + + func to_bbcode(cell_sizes: PackedInt32Array, bold: bool) -> String: + var cells := PackedStringArray() + for cell_index in _cells.size(): + var cell: String = _cells[cell_index] + if cell.strip_edges() == "--": + cell = create_line(cell_sizes[cell_index]) + if bold: + cell = "[b]%s[/b]" % cell + @warning_ignore("return_value_discarded") + cells.append("[cell]%s[/cell]" % cell) + return "|".join(cells) + + + func create_line(length: int) -> String: + var line := "" + for i in length: + line += "-" + return line + + + func _init(columns: int) -> void: + _columns = columns + + + func parse_row(line :String) -> bool: + # is line containing cells? + if line.find("|") == -1: + return false + _rows.append(Row.new(line.split("|"), _columns)) + return true + + + func calculate_max_cell_sizes() -> PackedInt32Array: + var cells_size := PackedInt32Array() + for column in _columns: + @warning_ignore("return_value_discarded") + cells_size.append(0) + + for row_index in _rows.size(): + var row: Row = _rows[row_index] + for cell_index in row._cells.size(): + var cell_size: int = cells_size[cell_index] + var size := row._cells[cell_index].length() + if size > cell_size: + cells_size[cell_index] = size + return cells_size + + + @warning_ignore("return_value_discarded") + func to_bbcode() -> PackedStringArray: + var cell_sizes := calculate_max_cell_sizes() + var bb_code := PackedStringArray() + + bb_code.append("[table=%d]" % _columns) + for row_index in _rows.size(): + bb_code.append(_rows[row_index].to_bbcode(cell_sizes, row_index==0)) + bb_code.append("[/table]\n") + return bb_code + + +func parse_table(lines: Array) -> PackedStringArray: + var line: String = lines[0] + var table := GdUnitMDReaderTable.new(line.count("|") + 1) + while not lines.is_empty(): + line = lines.pop_front() + if not table.parse_row(line): + break + return table.to_bbcode() + + +func is_table(line: String) -> bool: + return line.find("|") != -1 + + +func open_table(line: String) -> String: + _on_table_tag = true + return "[table=%d]" % (line.count("|") + 1) + + +func close_table() -> String: + _on_table_tag = false + return "[/table]" + + +func extract_cells(line: String, bold := false) -> String: + var cells := "" + for cell in line.split("|"): + if bold: + cell = "[b]%s[/b]" % cell + cells += "[cell]%s[/cell]" % cell + return cells + + +func process_image_references(p_regex: RegEx, p_input: String) -> String: + #return p_input + + # exists references? + var matches := p_regex.search_all(p_input) + if matches.is_empty(): + return p_input + # collect image references and remove_at it + var references := Dictionary() + var link_regex := regex("\\[(\\S+)\\]:(\\S+)([ ]\"(.*)\")?") + # create copy of original source to replace checked it + var input := p_input.replace("\r", "") + var extracted_references := p_input.replace("\r", "") + for reg_match in link_regex.search_all(input): + var line := reg_match.get_string(0) + "\n" + var ref := reg_match.get_string(1) + #var topl_tip = reg_match.get_string(4) + # collect reference and url + references[ref] = reg_match.get_string(2) + extracted_references = extracted_references.replace(line, "") + + # replace image references by collected url's + for reference_key: String in references.keys(): + var regex_key := regex("\\](\\[%s\\])" % reference_key) + for reg_match in regex_key.search_all(extracted_references): + var ref: String = reg_match.get_string(0) + var image_url: String = "](%s)" % references.get(reference_key) + extracted_references = extracted_references.replace(ref, image_url) + return extracted_references + + +@warning_ignore("return_value_discarded") +func process_image(p_regex: RegEx, p_input: String) -> String: + #return p_input + var to_replace := PackedStringArray() + var tool_tips := PackedStringArray() + # find all matches + var matches := p_regex.search_all(p_input) + if matches.is_empty(): + return p_input + for reg_match in matches: + # grap the parts to replace and store temporay because a direct replace will distort the offsets + to_replace.append(p_input.substr(reg_match.get_start(0), reg_match.get_end(0))) + # grap optional tool tips + tool_tips.append(reg_match.get_string(5)) + # finally replace all findings + for replace in to_replace: + var re := p_regex.sub(replace, "[img]$2[/img]") + p_input = p_input.replace(replace, re) + return await _process_external_image_resources(p_input) + + +func _process_external_image_resources(input: String) -> String: + @warning_ignore("return_value_discarded") + DirAccess.make_dir_recursive_absolute(image_download_folder) + # scan all img for external resources and download it + for value in _img_replace_regex.search_all(input): + if value.get_group_count() >= 1: + var image_url: String = value.get_string(1) + # if not a local resource we need to download it + if image_url.begins_with("http"): + if OS.is_stdout_verbose(): + prints("download image:", image_url) + var response := await _client.request_image(image_url) + if response.status() == 200: + var image := Image.new() + var error := image.load_png_from_buffer(response.get_body()) + if error != OK: + prints("Error creating image from response", error) + # replace characters where format characters + var new_url := image_download_folder + image_url.get_file().replace("_", "-") + if new_url.get_extension() != 'png': + new_url = new_url + '.png' + var err := image.save_png(new_url) + if err: + push_error("Can't save image to '%s'. Error: %s" % [new_url, error_string(err)]) + @warning_ignore("return_value_discarded") + _image_urls.append(new_url) + input = input.replace(image_url, new_url) + return input diff --git a/addons/gdUnit4/src/update/GdMarkDownReader.gd.uid b/addons/gdUnit4/src/update/GdMarkDownReader.gd.uid new file mode 100644 index 0000000..ffdd1e8 --- /dev/null +++ b/addons/gdUnit4/src/update/GdMarkDownReader.gd.uid @@ -0,0 +1 @@ +uid://bpa1x248oaapk diff --git a/addons/gdUnit4/src/update/GdUnitPatch.gd b/addons/gdUnit4/src/update/GdUnitPatch.gd new file mode 100644 index 0000000..daa20f7 --- /dev/null +++ b/addons/gdUnit4/src/update/GdUnitPatch.gd @@ -0,0 +1,20 @@ +class_name GdUnitPatch +extends RefCounted + +const PATCH_VERSION = "patch_version" + +var _version :GdUnit4Version + + +func _init(version_ :GdUnit4Version) -> void: + _version = version_ + + +func version() -> GdUnit4Version: + return _version + + +# this function needs to be implement +func execute() -> bool: + push_error("The function 'execute()' is not implemented at %s" % self) + return false diff --git a/addons/gdUnit4/src/update/GdUnitPatch.gd.uid b/addons/gdUnit4/src/update/GdUnitPatch.gd.uid new file mode 100644 index 0000000..f79462e --- /dev/null +++ b/addons/gdUnit4/src/update/GdUnitPatch.gd.uid @@ -0,0 +1 @@ +uid://bx5yhkl0j08e6 diff --git a/addons/gdUnit4/src/update/GdUnitPatcher.gd b/addons/gdUnit4/src/update/GdUnitPatcher.gd new file mode 100644 index 0000000..73d25c9 --- /dev/null +++ b/addons/gdUnit4/src/update/GdUnitPatcher.gd @@ -0,0 +1,75 @@ +class_name GdUnitPatcher +extends RefCounted + + +const _base_dir := "res://addons/gdUnit4/src/update/patches/" + +var _patches := Dictionary() + + +func scan(current :GdUnit4Version) -> void: + _scan(_base_dir, current) + + +func _scan(scan_path :String, current :GdUnit4Version) -> void: + _patches = Dictionary() + var patch_paths := _collect_patch_versions(scan_path, current) + for path in patch_paths: + prints("scan for patches checked '%s'" % path) + _patches[path] = _scan_patches(path) + + +func patch_count() -> int: + var count := 0 + for key :String in _patches.keys(): + @warning_ignore("unsafe_method_access") + count += _patches[key].size() + return count + + +func execute() -> void: + for key :String in _patches.keys(): + for path :String in _patches[key]: + var patch :GdUnitPatch = (load(key + "/" + path) as GDScript).new() + if patch: + prints("execute patch", patch.version(), patch.get_script().resource_path) + if not patch.execute(): + prints("error checked execution patch %s" % key + "/" + path) + + +func _collect_patch_versions(scan_path :String, current :GdUnit4Version) -> PackedStringArray: + if not DirAccess.dir_exists_absolute(scan_path): + return PackedStringArray() + var patches := Array() + var dir := DirAccess.open(scan_path) + if dir != null: + @warning_ignore("return_value_discarded") + dir.list_dir_begin() # TODO GODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547 + var next := "." + while next != "": + next = dir.get_next() + if next.is_empty() or next == "." or next == "..": + continue + var version := GdUnit4Version.parse(next) + if version.is_greater(current): + patches.append(scan_path + next) + patches.sort() + return PackedStringArray(patches) + + +func _scan_patches(path :String) -> PackedStringArray: + var patches := Array() + var dir := DirAccess.open(path) + if dir != null: + @warning_ignore("return_value_discarded") + dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547 + var next := "." + while next != "": + next = dir.get_next() + # step over directory links and .uid files + if next.is_empty() or next == "." or next == ".." or next.ends_with(".uid"): + continue + patches.append(next) + # make sorted from lowest to high version + patches.sort() + return PackedStringArray(patches) diff --git a/addons/gdUnit4/src/update/GdUnitPatcher.gd.uid b/addons/gdUnit4/src/update/GdUnitPatcher.gd.uid new file mode 100644 index 0000000..4f655bc --- /dev/null +++ b/addons/gdUnit4/src/update/GdUnitPatcher.gd.uid @@ -0,0 +1 @@ +uid://deeylfkrnf6jc diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.gd b/addons/gdUnit4/src/update/GdUnitUpdate.gd new file mode 100644 index 0000000..97b7fdd --- /dev/null +++ b/addons/gdUnit4/src/update/GdUnitUpdate.gd @@ -0,0 +1,305 @@ +@tool +extends Container + +const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd") +const GdUnitUpdateClient := preload("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd") +const GDUNIT_TEMP := "user://tmp" + +@onready var _progress_content: RichTextLabel = %message +@onready var _progress_bar: TextureProgressBar = %progress +@onready var _cancel_btn: Button = %cancel +@onready var _update_btn: Button = %update +@onready var _spinner_img := GdUnitUiTools.get_spinner() + + +var _debug_mode := false +var _update_client :GdUnitUpdateClient +var _download_url :String + + +func _ready() -> void: + init_progress(6) + + +func _process(_delta :float) -> void: + if _progress_content != null and _progress_content.is_visible_in_tree(): + _progress_content.queue_redraw() + + +func init_progress(max_value: int) -> void: + _cancel_btn.disabled = false + _update_btn.disabled = false + _progress_bar.max_value = max_value + _progress_bar.value = 1 + message_h4("Press [Update] to start.", Color.GREEN, false) + + +func setup(update_client: GdUnitUpdateClient, download_url: String) -> void: + _update_client = update_client + _download_url = download_url + + +func update_progress(message: String, color := Color.GREEN) -> void: + message_h4(message, color) + _progress_bar.value += 1 + if _debug_mode: + await get_tree().create_timer(3).timeout + await get_tree().create_timer(.2).timeout + + +func _colored(message: String, color: Color) -> String: + return "[color=#%s]%s[/color]" % [color.to_html(), message] + + +func message_h4(message: String, color: Color, show_spinner := true) -> void: + _progress_content.clear() + if show_spinner: + _progress_content.add_image(_spinner_img) + _progress_content.append_text(" [font_size=16]%s[/font_size]" % _colored(message, color)) + if _debug_mode: + prints(message) + + +@warning_ignore("return_value_discarded") +func run_update() -> void: + _cancel_btn.disabled = true + _update_btn.disabled = true + + await update_progress("Downloading the update.") + await download_release() + await update_progress("Extracting") + var zip_file := temp_dir() + "/update.zip" + var tmp_path := create_temp_dir("update") + var result :Variant = extract_zip(zip_file, tmp_path) + if result == null: + await update_progress("Update failed! .. Rollback.", Color.INDIAN_RED) + await get_tree().create_timer(3).timeout + _cancel_btn.disabled = false + _update_btn.disabled = false + init_progress(5) + hide() + return + + await update_progress("Uninstall GdUnit4.") + disable_gdUnit() + if not _debug_mode: + GdUnitFileAccess.delete_directory("res://addons/gdUnit4/") + # give editor time to react on deleted files + await get_tree().create_timer(1).timeout + + await update_progress("Install new GdUnit4 version.") + if _debug_mode: + copy_directory(tmp_path, "res://debug") + else: + copy_directory(tmp_path, "res://") + + await update_progress("Patch invalid UID's") + await patch_uids() + + await rebuild_project() + + await update_progress("New GdUnit version successfully installed, Restarting Godot please wait.") + await get_tree().create_timer(3).timeout + enable_gdUnit() + hide() + GdUnitFileAccess.delete_directory("res://addons/.gdunit_update") + restart_godot() + + +func patch_uids(path := "res://addons/gdUnit4/src/") -> void: + var to_reimport: PackedStringArray + for file in DirAccess.get_files_at(path): + var file_path := path.path_join(file) + var ext := file.get_extension() + + if ext == "tscn" or ext == "scn" or ext == "tres" or ext == "res": + message_h4("Patch GdUnit4 scene: '%s'" % file, Color.WEB_GREEN) + remove_uids_from_file(file_path) + elif FileAccess.file_exists(file_path + ".import"): + to_reimport.append(file_path) + + if not to_reimport.is_empty(): + message_h4("Reimport resources '%s'" % ", ".join(to_reimport), Color.WEB_GREEN) + if Engine.is_editor_hint(): + EditorInterface.get_resource_filesystem().reimport_files(to_reimport) + + for dir in DirAccess.get_directories_at(path): + if not dir.begins_with("."): + patch_uids(path.path_join(dir)) + await get_tree().process_frame + + +func remove_uids_from_file(file_path: String) -> bool: + var file := FileAccess.open(file_path, FileAccess.READ) + if file == null: + print("Failed to open file: ", file_path) + return false + + var original_content := file.get_as_text() + file.close() + + # Remove UIDs using regex + var regex := RegEx.new() + regex.compile("(\\[ext_resource[^\\]]*?)\\s+uid=\"uid://[^\"]*\"") + + var modified_content := regex.sub(original_content, "$1", true) + + # Check if any changes were made + if original_content != modified_content: + prints("Patched invalid uid's out in '%s'" % file_path) + # Write the modified content back + file = FileAccess.open(file_path, FileAccess.WRITE) + if file == null: + print("Failed to write to file: ", file_path) + return false + + file.store_string(modified_content) + file.close() + return true + + return false + + +func restart_godot() -> void: + prints("Force restart Godot") + EditorInterface.restart_editor(true) + + +@warning_ignore("return_value_discarded") +func enable_gdUnit() -> void: + var enabled_plugins := PackedStringArray() + if ProjectSettings.has_setting("editor_plugins/enabled"): + enabled_plugins = ProjectSettings.get_setting("editor_plugins/enabled") + if not enabled_plugins.has("res://addons/gdUnit4/plugin.cfg"): + enabled_plugins.append("res://addons/gdUnit4/plugin.cfg") + ProjectSettings.set_setting("editor_plugins/enabled", enabled_plugins) + ProjectSettings.save() + + +func disable_gdUnit() -> void: + EditorInterface.set_plugin_enabled("gdUnit4", false) + + +func temp_dir() -> String: + if not DirAccess.dir_exists_absolute(GDUNIT_TEMP): + @warning_ignore("return_value_discarded") + DirAccess.make_dir_recursive_absolute(GDUNIT_TEMP) + return GDUNIT_TEMP + + +func create_temp_dir(folder_name :String) -> String: + var new_folder := temp_dir() + "/" + folder_name + GdUnitFileAccess.delete_directory(new_folder) + if not DirAccess.dir_exists_absolute(new_folder): + @warning_ignore("return_value_discarded") + DirAccess.make_dir_recursive_absolute(new_folder) + return new_folder + + +func copy_directory(from_dir: String, to_dir: String) -> bool: + if not DirAccess.dir_exists_absolute(from_dir): + printerr("Source directory not found '%s'" % from_dir) + return false + # check if destination exists + if not DirAccess.dir_exists_absolute(to_dir): + # create it + var err := DirAccess.make_dir_recursive_absolute(to_dir) + if err != OK: + printerr("Can't create directory '%s'. Error: %s" % [to_dir, error_string(err)]) + return false + var source_dir := DirAccess.open(from_dir) + var dest_dir := DirAccess.open(to_dir) + if source_dir != null: + @warning_ignore("return_value_discarded") + source_dir.list_dir_begin() + var next := "." + + while next != "": + next = source_dir.get_next() + if next == "" or next == "." or next == "..": + continue + var source := source_dir.get_current_dir() + "/" + next + var dest := dest_dir.get_current_dir() + "/" + next + if source_dir.current_is_dir(): + @warning_ignore("return_value_discarded") + copy_directory(source + "/", dest) + continue + var err := source_dir.copy(source, dest) + if err != OK: + printerr("Error checked copy file '%s' to '%s'" % [source, dest]) + return false + return true + else: + printerr("Directory not found: " + from_dir) + return false + + +func extract_zip(zip_package: String, dest_path: String) -> Variant: + var zip: ZIPReader = ZIPReader.new() + var err := zip.open(zip_package) + if err != OK: + printerr("Extracting `%s` failed! Please collect the error log and report this. Error Code: %s" % [zip_package, err]) + return null + var zip_entries: PackedStringArray = zip.get_files() + # Get base path and step over archive folder + var archive_path := zip_entries[0] + zip_entries.remove_at(0) + + for zip_entry in zip_entries: + var new_file_path: String = dest_path + "/" + zip_entry.replace(archive_path, "") + if zip_entry.ends_with("/"): + @warning_ignore("return_value_discarded") + DirAccess.make_dir_recursive_absolute(new_file_path) + continue + var file: FileAccess = FileAccess.open(new_file_path, FileAccess.WRITE) + file.store_buffer(zip.read_file(zip_entry)) + @warning_ignore("return_value_discarded") + zip.close() + return dest_path + + +func download_release() -> void: + var zip_file := GdUnitFileAccess.temp_dir() + "/update.zip" + var response :GdUnitUpdateClient.HttpResponse + if _debug_mode: + response = GdUnitUpdateClient.HttpResponse.new(200, PackedByteArray()) + zip_file = "res://update.zip" + return + + response = await _update_client.request_zip_package(_download_url, zip_file) + if response.status() != 200: + push_warning("Update information cannot be retrieved from GitHub! \n Error code: %d : %s" % [response.status(), response.response()]) + message_h4("Download the update failed! Try it later again.", Color.INDIAN_RED) + await get_tree().create_timer(3).timeout + + +func rebuild_project() -> void: + # Check if this is a Godot .NET runtime instance + if not ClassDB.class_exists("CSharpScript"): + return + + update_progress("Rebuild the project ...") + await get_tree().process_frame + + var output := [] + var exit_code := OS.execute("dotnet", ["build"], output) + if exit_code == -1: + message_h4("Rebuild the project failed, check your project dependencies.", Color.INDIAN_RED) + await get_tree().create_timer(3).timeout + return + + for out: String in output: + print_rich("[color=DEEP_SKY_BLUE] %s" % out.strip_edges()) + await get_tree().process_frame + + +func _on_confirmed() -> void: + await run_update() + + +func _on_cancel_pressed() -> void: + hide() + + +func _on_update_pressed() -> void: + await run_update() diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.gd.uid b/addons/gdUnit4/src/update/GdUnitUpdate.gd.uid new file mode 100644 index 0000000..5440eeb --- /dev/null +++ b/addons/gdUnit4/src/update/GdUnitUpdate.gd.uid @@ -0,0 +1 @@ +uid://ducbt6rux1vdp diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.tscn b/addons/gdUnit4/src/update/GdUnitUpdate.tscn new file mode 100644 index 0000000..20d60b7 --- /dev/null +++ b/addons/gdUnit4/src/update/GdUnitUpdate.tscn @@ -0,0 +1,100 @@ +[gd_scene load_steps=6 format=3 uid="uid://2eahgaw88y6q"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/update/GdUnitUpdate.gd" id="1"] + +[sub_resource type="Gradient" id="Gradient_wilsr"] +colors = PackedColorArray(0.151276, 0.151276, 0.151276, 1, 1, 1, 1, 1) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_45cww"] +gradient = SubResource("Gradient_wilsr") +fill_to = Vector2(0.75641, 0) + +[sub_resource type="Gradient" id="Gradient_i0qp8"] +colors = PackedColorArray(1, 1, 1, 1, 0.20871, 0.20871, 0.20871, 1) + +[sub_resource type="GradientTexture2D" id="GradientTexture2D_wilsr"] +gradient = SubResource("Gradient_i0qp8") +fill_from = Vector2(0.794872, 0) +fill_to = Vector2(0, 0) + +[node name="GdUnitUpdate" type="MarginContainer"] +clip_contents = true +custom_minimum_size = Vector2(0, 80) +anchors_preset = 10 +anchor_right = 1.0 +offset_bottom = 80.0 +grow_horizontal = 2 +size_flags_horizontal = 3 +theme_override_constants/margin_left = 10 +theme_override_constants/margin_right = 10 +script = ExtResource("1") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 2 + +[node name="Panel" type="Panel" parent="VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="message" type="RichTextLabel" parent="VBoxContainer/Panel"] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +bbcode_enabled = true +text = "aaaaa" +fit_content = true +scroll_active = false +shortcut_keys_enabled = false + +[node name="Panel2" type="Panel" parent="VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="progress" type="TextureProgressBar" parent="VBoxContainer/Panel2"] +unique_name_in_owner = true +auto_translate_mode = 2 +clip_contents = true +custom_minimum_size = Vector2(0, 20) +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +localize_numeral_system = false +min_value = 1.0 +max_value = 5.0 +value = 1.0 +rounded = true +allow_greater = true +nine_patch_stretch = true +texture_under = SubResource("GradientTexture2D_45cww") +texture_progress = SubResource("GradientTexture2D_wilsr") +tint_under = Color(0.0235294, 0.145098, 0.168627, 1) +tint_progress = Color(0.288912, 0.233442, 0.533772, 1) + +[node name="PanelContainer" type="MarginContainer" parent="VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/PanelContainer"] +layout_mode = 2 +theme_override_constants/separation = 10 +alignment = 2 + +[node name="update" type="Button" parent="VBoxContainer/PanelContainer/HBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Update" + +[node name="cancel" type="Button" parent="VBoxContainer/PanelContainer/HBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Cancel" + +[connection signal="pressed" from="VBoxContainer/PanelContainer/HBoxContainer/update" to="." method="_on_update_pressed"] +[connection signal="pressed" from="VBoxContainer/PanelContainer/HBoxContainer/cancel" to="." method="_on_cancel_pressed"] diff --git a/addons/gdUnit4/src/update/GdUnitUpdateClient.gd b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd new file mode 100644 index 0000000..b4f54b7 --- /dev/null +++ b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd @@ -0,0 +1,98 @@ +@tool +extends Node + +signal request_completed(response: HttpResponse) + +class HttpResponse: + var _http_status: int + var _body: PackedByteArray + + + func _init(http_status: int, body: PackedByteArray) -> void: + _http_status = http_status + _body = body + + + func status() -> int: + return _http_status + + + func response() -> Variant: + if _http_status != 200: + return _body.get_string_from_utf8() + + var test_json_conv := JSON.new() + @warning_ignore("return_value_discarded") + var error := test_json_conv.parse(_body.get_string_from_utf8()) + if error != OK: + return "HttpResponse: %s Error: %s" % [error_string(error), _body.get_string_from_utf8()] + return test_json_conv.get_data() + + func get_body() -> PackedByteArray: + return _body + + +var _http_request := HTTPRequest.new() + + +func _ready() -> void: + add_child(_http_request) + @warning_ignore("return_value_discarded") + _http_request.request_completed.connect(_on_request_completed) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + if is_instance_valid(_http_request): + _http_request.queue_free() + + +#func list_tags() -> void: +# _http_request.connect("request_completed",Callable(self,"_response_request_tags")) +# var error = _http_request.request("https://api.github.com/repos/MikeSchulze/gdUnit4/tags") +# if error != OK: +# push_error("An error occurred in the HTTP request.") + + +func request_latest_version() -> HttpResponse: + var error := _http_request.request("https://api.github.com/repos/MikeSchulze/gdUnit4/tags") + if error != OK: + var message := "Request latest version failed, %s" % error_string(error) + return HttpResponse.new(error, message.to_utf8_buffer()) + return await self.request_completed + + +func request_releases() -> HttpResponse: + var error := _http_request.request("https://api.github.com/repos/MikeSchulze/gdUnit4/releases") + if error != OK: + var message := "request_releases failed: %d" % error + return HttpResponse.new(error, message.to_utf8_buffer()) + return await self.request_completed + + +func request_image(url: String) -> HttpResponse: + var error := _http_request.request(url) + if error != OK: + var message := "request_image failed: %d" % error + return HttpResponse.new(error, message.to_utf8_buffer()) + return await self.request_completed + + +func request_zip_package(url: String, file: String) -> HttpResponse: + _http_request.set_download_file(file) + var error := _http_request.request(url) + if error != OK: + var message := "request_zip_package failed: %d" % error + return HttpResponse.new(error, message.to_utf8_buffer()) + return await self.request_completed + + +func extract_latest_version(response: HttpResponse) -> GdUnit4Version: + var body: Array = response.response() + return GdUnit4Version.parse(str(body[0]["name"])) + + +func _on_request_completed(_result: int, response_http_status: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if _http_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + _http_request.set_download_file("") + request_completed.emit(HttpResponse.new(response_http_status, body)) diff --git a/addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid new file mode 100644 index 0000000..1bee805 --- /dev/null +++ b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid @@ -0,0 +1 @@ +uid://baqn0h6e6kb4x diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd new file mode 100644 index 0000000..aba5e18 --- /dev/null +++ b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd @@ -0,0 +1,206 @@ +@tool +extends MarginContainer + +#signal request_completed(response) + +const GdMarkDownReader = preload("res://addons/gdUnit4/src/update/GdMarkDownReader.gd") +const GdUnitUpdateClient = preload("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd") +const GdUnitUpdateProgress = preload("res://addons/gdUnit4/src/update/GdUnitUpdate.gd") + +@onready var _md_reader: GdMarkDownReader = GdMarkDownReader.new() +@onready var _update_client: GdUnitUpdateClient = $GdUnitUpdateClient +@onready var _header: Label = $Panel/GridContainer/PanelContainer/header +@onready var _update_button: Button = $Panel/GridContainer/Panel/HBoxContainer/update +@onready var _content: RichTextLabel = $Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer/content +@onready var _update_progress :GdUnitUpdateProgress = %update_banner + +var _debug_mode := false +var _patcher := GdUnitPatcher.new() +var _current_version := GdUnit4Version.current() + + +func _ready() -> void: + _update_button.set_disabled(false) + _md_reader.set_http_client(_update_client) + @warning_ignore("return_value_discarded") + #GdUnitFonts.init_fonts(_content) + _update_progress.set_visible(false) + _update_progress.hidden.connect(func() -> void: + _update_button.set_disabled(false) + ) + + +func request_releases() -> bool: + if _debug_mode: + _update_progress._debug_mode = _debug_mode + _header.text = "A new version 'v4.4.4' is available" + _update_button.set_disabled(false) + return true + + var response :GdUnitUpdateClient.HttpResponse = await _update_client.request_latest_version() + if response.status() != 200: + _header.text = "Update information cannot be retrieved from GitHub!" + message_h4("\n\nError: %s" % response.response(), Color.INDIAN_RED) + return false + var latest_version := _update_client.extract_latest_version(response) + # if same version exit here no update need + if latest_version.is_greater(_current_version): + _patcher.scan(_current_version) + _header.text = "A new version '%s' is available" % latest_version + var download_zip_url := extract_zip_url(response) + _update_progress.setup(_update_client, download_zip_url) + _update_button.set_disabled(false) + return true + else: + _header.text = "No update is available." + _update_button.set_disabled(true) + return false + + +func _colored(message_: String, color: Color) -> String: + return "[color=#%s]%s[/color]" % [color.to_html(), message_] + + +func message_h4(message_: String, color: Color, clear := true) -> void: + if clear: + _content.clear() + _content.append_text("[font_size=16]%s[/font_size]" % _colored(message_, color)) + + +func message(message_: String, color: Color) -> void: + _content.clear() + _content.append_text(_colored(message_, color)) + + +func _process(_delta: float) -> void: + if _content != null and _content.is_visible_in_tree(): + _content.queue_redraw() + + +func show_update() -> void: + if not GdUnitSettings.is_update_notification_enabled(): + _header.text = "No update is available." + message_h4("The search for updates is deactivated.", Color.CORNFLOWER_BLUE) + _update_button.set_disabled(true) + return + + if not await request_releases(): + return + _update_button.set_disabled(true) + + prints("Scan for GdUnit4 Update ...") + message_h4("\n\n\nRequest release infos ... ", Color.SNOW) + _content.add_image(GdUnitUiTools.get_spinner(), 32, 32) + + var content: String + if _debug_mode: + await get_tree().create_timer(.2).timeout + var template := FileAccess.open("res://addons/gdUnit4/test/update/resources/http_response_releases.txt", FileAccess.READ).get_as_text() + content = await _md_reader.to_bbcode(template) + else: + var response :GdUnitUpdateClient.HttpResponse = await _update_client.request_releases() + if response.status() == 200: + content = await extract_releases(response, _current_version) + else: + message_h4("\n\n\nError checked request available releases!", Color.INDIAN_RED) + return + + # finally force rescan to import images as textures + if Engine.is_editor_hint(): + await rescan() + message(content, Color.CADET_BLUE) + _update_button.set_disabled(false) + + + +func extract_zip_url(response: GdUnitUpdateClient.HttpResponse) -> String: + var body :Array = response.response() + return body[0]["zipball_url"] + + +func extract_releases(response: GdUnitUpdateClient.HttpResponse, current_version: GdUnit4Version) -> String: + await get_tree().process_frame + var result := "" + for release :Dictionary in response.response(): + var release_version := str(release["tag_name"]) + if GdUnit4Version.parse(release_version).equals(current_version): + break + var release_description := _colored("

GdUnit Release %s

" % release_version, Color.CORNFLOWER_BLUE) + release_description += "\n" + release_description += release["body"] + release_description += "\n\n" + result += await _md_reader.to_bbcode(release_description) + return result + + +func rescan() -> void: + if Engine.is_editor_hint(): + if OS.is_stdout_verbose(): + prints(".. reimport release resources") + var fs := EditorInterface.get_resource_filesystem() + fs.scan() + while fs.is_scanning(): + if OS.is_stdout_verbose(): + progressBar(fs.get_scanning_progress() * 100 as int) + await get_tree().process_frame + await get_tree().process_frame + await get_tree().create_timer(1).timeout + + +func progressBar(p_progress: int) -> void: + if p_progress < 0: + p_progress = 0 + if p_progress > 100: + p_progress = 100 + printraw("scan [%-50s] %-3d%%\r" % ["".lpad(int(p_progress/2.0), "#").rpad(50, "-"), p_progress]) + + +@warning_ignore("return_value_discarded") +func _on_update_pressed() -> void: + _update_button.set_disabled(true) + # close all opend scripts before start the update + if not _debug_mode: + GdUnitScriptEditorControls.close_open_editor_scripts() + # copy update source to a temp because the update is deleting the whole gdUnit folder + DirAccess.make_dir_absolute("res://addons/.gdunit_update") + DirAccess.copy_absolute("res://addons/gdUnit4/src/update/GdUnitUpdate.tscn", "res://addons/.gdunit_update/GdUnitUpdate.tscn") + DirAccess.copy_absolute("res://addons/gdUnit4/src/update/GdUnitUpdate.gd", "res://addons/.gdunit_update/GdUnitUpdate.gd") + var source := FileAccess.open("res://addons/gdUnit4/src/update/GdUnitUpdate.tscn", FileAccess.READ) + var content := source.get_as_text().replace("res://addons/gdUnit4/src/update/GdUnitUpdate.gd", "res://addons/.gdunit_update/GdUnitUpdate.gd") + var dest := FileAccess.open("res://addons/.gdunit_update/GdUnitUpdate.tscn", FileAccess.WRITE) + dest.store_string(content) + _update_progress.set_visible(true) + + +func _on_show_next_toggled(enabled: bool) -> void: + GdUnitSettings.set_update_notification(enabled) + + +func _on_cancel_pressed() -> void: + hide() + + +func _on_content_meta_clicked(meta: String) -> void: + var properties: Dictionary = str_to_var(meta) + if properties.has("url"): + @warning_ignore("return_value_discarded") + OS.shell_open(str(properties.get("url"))) + + +func _on_content_meta_hover_started(meta: String) -> void: + var properties: Dictionary = str_to_var(meta) + if properties.has("tool_tip"): + _content.set_tooltip_text(str(properties.get("tool_tip"))) + + +@warning_ignore("unused_parameter") +func _on_content_meta_hover_ended(meta: String) -> void: + _content.set_tooltip_text("") + + +func _on_visibility_changed() -> void: + if not is_visible_in_tree(): + return + if _update_progress != null: + _update_progress.set_visible(false) + await show_update() diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid new file mode 100644 index 0000000..1ef7729 --- /dev/null +++ b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid @@ -0,0 +1 @@ +uid://h8cfxu45k35v diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn b/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn new file mode 100644 index 0000000..46119f1 --- /dev/null +++ b/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn @@ -0,0 +1,97 @@ +[gd_scene load_steps=4 format=3 uid="uid://0xyeci1tqebj"] + +[ext_resource type="Script" path="res://addons/gdUnit4/src/update/GdUnitUpdateNotify.gd" id="1_112wo"] +[ext_resource type="Script" path="res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd" id="2_18asx"] +[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/update/GdUnitUpdate.tscn" id="3_x87h6"] + +[node name="Control" type="MarginContainer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1_112wo") + +[node name="GdUnitUpdateClient" type="Node" parent="."] +script = ExtResource("2_18asx") + +[node name="Panel" type="Panel" parent="."] +layout_mode = 2 + +[node name="GridContainer" type="VBoxContainer" parent="Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +alignment = 1 + +[node name="PanelContainer" type="MarginContainer" parent="Panel/GridContainer"] +layout_mode = 2 +theme_override_constants/margin_left = 4 +theme_override_constants/margin_top = 4 +theme_override_constants/margin_right = 4 +theme_override_constants/margin_bottom = 4 + +[node name="header" type="Label" parent="Panel/GridContainer/PanelContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 9 + +[node name="PanelContainer2" type="PanelContainer" parent="Panel/GridContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="ScrollContainer" type="ScrollContainer" parent="Panel/GridContainer/PanelContainer2"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="MarginContainer" type="MarginContainer" parent="Panel/GridContainer/PanelContainer2/ScrollContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="content" type="RichTextLabel" parent="Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +bbcode_enabled = true + +[node name="update_banner" parent="Panel/GridContainer" instance=ExtResource("3_x87h6")] +unique_name_in_owner = true +visible = false +layout_mode = 2 +size_flags_horizontal = 1 +size_flags_vertical = 8 + +[node name="Panel" type="MarginContainer" parent="Panel/GridContainer"] +layout_mode = 2 +size_flags_vertical = 8 +theme_override_constants/margin_left = 4 +theme_override_constants/margin_top = 4 +theme_override_constants/margin_right = 4 +theme_override_constants/margin_bottom = 4 + +[node name="HBoxContainer" type="HBoxContainer" parent="Panel/GridContainer/Panel"] +use_parent_material = true +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="update" type="Button" parent="Panel/GridContainer/Panel/HBoxContainer"] +custom_minimum_size = Vector2(100, 40) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 4 +text = "Update" + +[connection signal="visibility_changed" from="." to="." method="_on_visibility_changed"] +[connection signal="meta_clicked" from="Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer/content" to="." method="_on_content_meta_clicked"] +[connection signal="meta_hover_ended" from="Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer/content" to="." method="_on_content_meta_hover_ended"] +[connection signal="meta_hover_started" from="Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer/content" to="." method="_on_content_meta_hover_started"] +[connection signal="pressed" from="Panel/GridContainer/Panel/HBoxContainer/update" to="." method="_on_update_pressed"] diff --git a/addons/gdUnit4/src/update/assets/border_bottom.png b/addons/gdUnit4/src/update/assets/border_bottom.png new file mode 100644 index 0000000..aa16bb7 Binary files /dev/null and b/addons/gdUnit4/src/update/assets/border_bottom.png differ diff --git a/addons/gdUnit4/src/update/assets/border_bottom.png.import b/addons/gdUnit4/src/update/assets/border_bottom.png.import new file mode 100644 index 0000000..9f44b35 --- /dev/null +++ b/addons/gdUnit4/src/update/assets/border_bottom.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://1xpm12s8lj2c" +path="res://.godot/imported/border_bottom.png-30d66a4c67e3a03ad191e37cdf16549d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gdUnit4/src/update/assets/border_bottom.png" +dest_files=["res://.godot/imported/border_bottom.png-30d66a4c67e3a03ad191e37cdf16549d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/addons/gdUnit4/src/update/assets/border_top.png b/addons/gdUnit4/src/update/assets/border_top.png new file mode 100644 index 0000000..b1b1039 Binary files /dev/null and b/addons/gdUnit4/src/update/assets/border_top.png differ diff --git a/addons/gdUnit4/src/update/assets/border_top.png.import b/addons/gdUnit4/src/update/assets/border_top.png.import new file mode 100644 index 0000000..13b0fbe --- /dev/null +++ b/addons/gdUnit4/src/update/assets/border_top.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cg8b8r18y0wg8" +path="res://.godot/imported/border_top.png-c47cbebdb755144731c6ae309e18bbaa.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gdUnit4/src/update/assets/border_top.png" +dest_files=["res://.godot/imported/border_top.png-c47cbebdb755144731c6ae309e18bbaa.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/addons/gdUnit4/src/update/assets/dot1.png b/addons/gdUnit4/src/update/assets/dot1.png new file mode 100644 index 0000000..d5ea77b Binary files /dev/null and b/addons/gdUnit4/src/update/assets/dot1.png differ diff --git a/addons/gdUnit4/src/update/assets/dot1.png.import b/addons/gdUnit4/src/update/assets/dot1.png.import new file mode 100644 index 0000000..7854f8b --- /dev/null +++ b/addons/gdUnit4/src/update/assets/dot1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://wpa4hhub6rx1" +path="res://.godot/imported/dot1.png-380baf1b5247addda93bce3c799aa4e7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gdUnit4/src/update/assets/dot1.png" +dest_files=["res://.godot/imported/dot1.png-380baf1b5247addda93bce3c799aa4e7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/addons/gdUnit4/src/update/assets/dot2.png b/addons/gdUnit4/src/update/assets/dot2.png new file mode 100644 index 0000000..4a74498 Binary files /dev/null and b/addons/gdUnit4/src/update/assets/dot2.png differ diff --git a/addons/gdUnit4/src/update/assets/dot2.png.import b/addons/gdUnit4/src/update/assets/dot2.png.import new file mode 100644 index 0000000..019779d --- /dev/null +++ b/addons/gdUnit4/src/update/assets/dot2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dcqkcbwdmbn8t" +path="res://.godot/imported/dot2.png-86a9db80ef4413e353c4339ad8f68a5f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gdUnit4/src/update/assets/dot2.png" +dest_files=["res://.godot/imported/dot2.png-86a9db80ef4413e353c4339ad8f68a5f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/addons/gdUnit4/src/update/assets/embedded.png b/addons/gdUnit4/src/update/assets/embedded.png new file mode 100644 index 0000000..15cb9ab Binary files /dev/null and b/addons/gdUnit4/src/update/assets/embedded.png differ diff --git a/addons/gdUnit4/src/update/assets/embedded.png.import b/addons/gdUnit4/src/update/assets/embedded.png.import new file mode 100644 index 0000000..b0160ba --- /dev/null +++ b/addons/gdUnit4/src/update/assets/embedded.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d1nbcc4xqi7ni" +path="res://.godot/imported/embedded.png-29390948772209a603567d24f8766495.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gdUnit4/src/update/assets/embedded.png" +dest_files=["res://.godot/imported/embedded.png-29390948772209a603567d24f8766495.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/addons/gdUnit4/src/update/assets/horizontal-line2.png b/addons/gdUnit4/src/update/assets/horizontal-line2.png new file mode 100644 index 0000000..66aa098 Binary files /dev/null and b/addons/gdUnit4/src/update/assets/horizontal-line2.png differ diff --git a/addons/gdUnit4/src/update/assets/horizontal-line2.png.import b/addons/gdUnit4/src/update/assets/horizontal-line2.png.import new file mode 100644 index 0000000..1f77b42 --- /dev/null +++ b/addons/gdUnit4/src/update/assets/horizontal-line2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cedusysfb46fp" +path="res://.godot/imported/horizontal-line2.png-92618e6ee5cc9002847547a8c9deadbc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gdUnit4/src/update/assets/horizontal-line2.png" +dest_files=["res://.godot/imported/horizontal-line2.png-92618e6ee5cc9002847547a8c9deadbc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/common/game_log.gd b/common/game_log.gd new file mode 100644 index 0000000..6c79935 --- /dev/null +++ b/common/game_log.gd @@ -0,0 +1,21 @@ +extends Node +## Writes messages about what the game is doing, for finding problems. +## +## Registered as the [code]GameLog[/code] autoload. Use [method debug] instead of +## [code]print()[/code]: the messages show in debug builds and the editor, and are left +## out of released games. Problems the player may notice go through [method warning]. + + +## Prints [param parts] joined together, like [code]print()[/code], in debug builds only. +func debug(...parts: Array) -> void: + if OS.is_debug_build(): + print(_join(parts)) + + +## Reports a problem in the editor's debugger and the console, in every build. +func warning(...parts: Array) -> void: + push_warning(_join(parts)) + + +func _join(parts: Array) -> String: + return "".join(parts.map(func(part: Variant) -> String: return str(part))) diff --git a/common/game_log.gd.uid b/common/game_log.gd.uid new file mode 100644 index 0000000..9dde246 --- /dev/null +++ b/common/game_log.gd.uid @@ -0,0 +1 @@ +uid://c37mlo6svk03q diff --git a/common/list_row.gd b/common/list_row.gd new file mode 100644 index 0000000..1e2cac7 --- /dev/null +++ b/common/list_row.gd @@ -0,0 +1,34 @@ +class_name ListRow +extends PanelContainer +## One item in a list of names, such as the inspiration list or a sync result column. +## +## Every row has its own background, alternating between the theme's [code]ListRowEven[/code] +## and [code]ListRowOdd[/code] styles, so it is clear where an item starts even when its +## text wraps over several lines. + +## The label showing the item's text. +var label: Label + +## The item's text. +var text: String: + get: + return label.text + + +## Creates the row for item number [param row] (counting from 0) in a list. The text uses +## the theme's [param label_style], e.g. [code]TextLarge[/code]. +static func create( + item_text: String, + row: int, + label_style: StringName, + alignment: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT, +) -> ListRow: + var list_row: ListRow = ListRow.new() + list_row.theme_type_variation = &"ListRowOdd" if row % 2 != 0 else &"ListRowEven" + list_row.label = Label.new() + list_row.label.theme_type_variation = label_style + list_row.label.text = item_text + list_row.label.horizontal_alignment = alignment + list_row.label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + list_row.add_child(list_row.label) + return list_row diff --git a/common/list_row.gd.uid b/common/list_row.gd.uid new file mode 100644 index 0000000..c92d518 --- /dev/null +++ b/common/list_row.gd.uid @@ -0,0 +1 @@ +uid://c2o2qunso5n8l diff --git a/data/player_object.gd b/data/player_object.gd new file mode 100644 index 0000000..b569bc6 --- /dev/null +++ b/data/player_object.gd @@ -0,0 +1,70 @@ +class_name PlayerObject +extends RefCounted +## A player in the quiz: name, score and character. The players are kept in +## [code]Settings.player_array[/code]. + +signal first_point_triggered +signal match_point_triggered +signal winner_triggered +signal point_given_sound +signal player_point_given +signal player_point_taken + +## Picture shown until the player picks a character. +const NO_CHARACTER_PATH: String = "res://noCharacter.png" + +@export var player_name: String +@export var player_score: int + +## Unique id that never changes, unlike the player's position in Settings.player_array. +var id: String = _generate_uuid() + +var is_first_point: bool = true +var character: Texture = load(NO_CHARACTER_PATH) + + +func _init(new_name: String) -> void: + player_name = new_name + player_score = 0 + + +## Returns a random version 4 UUID, e.g. "3f2b8c1e-9a4d-4e7f-b2c6-5d8e1f0a7b93". +static func _generate_uuid() -> String: + var bytes: PackedByteArray = Crypto.new().generate_random_bytes(16) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + var hex: String = bytes.hex_encode() + return "%s-%s-%s-%s-%s" % [hex.substr(0, 8), hex.substr(8, 4), hex.substr(12, 4), + hex.substr(16, 4), hex.substr(20, 12)] + + +func add_point() -> void: + if player_score < Settings.winning_score: + player_score += 1 + player_point_given.emit() + var first_point: bool = player_score == 1 && is_first_point + if player_score == 1: + is_first_point = false + # With a low winning score the first point can also be match point or the win, + # so the more important event is checked first + if player_score == Settings.winning_score: + winner_triggered.emit() + elif player_score == Settings.winning_score - 1: + match_point_triggered.emit() + elif first_point: + first_point_triggered.emit() + else: + point_given_sound.emit() + + +func minus_point() -> void: + if player_score > 0: + player_score -= 1 + player_point_taken.emit() + if player_score == 0: + is_first_point = true + + +func reset_points() -> void: + player_score = 0 + is_first_point = true diff --git a/playerObject.gd.uid b/data/player_object.gd.uid similarity index 100% rename from playerObject.gd.uid rename to data/player_object.gd.uid diff --git a/playlist.gd b/data/playlist.gd similarity index 76% rename from playlist.gd rename to data/playlist.gd index 735829d..033283f 100644 --- a/playlist.gd +++ b/data/playlist.gd @@ -1,26 +1,32 @@ extends Node +## The songs of the current quiz, in the order they were added, and which of them are +## played, answered and playing. +## +## Registered as the [code]Playlist[/code] autoload. var song_object_array: Array[SongObject] = [] var latest_played_song: int = 0 var currently_playing_song: int = -1 - var first_song_downloaded: bool = false var first_song_played: bool = false - var number_of_tracks_to_preload: int = 1 var is_song_downloading: bool = false + func get_next_song() -> SongObject: return song_object_array[latest_played_song] + func set_latest_played_song() -> void: latest_played_song += 1 if latest_played_song > song_object_array.size() - 1: latest_played_song = song_object_array.size() - 1 + func update_last_played_song() -> void: latest_played_song = song_object_array.size() + func get_preloaded_songs() -> int: var number_of_tracks_preloaded: int = 0 for song: SongObject in song_object_array: @@ -28,23 +34,26 @@ func get_preloaded_songs() -> int: number_of_tracks_preloaded += 1 return number_of_tracks_preloaded + func should_preload_more_songs() -> bool: return number_of_tracks_to_preload > get_preloaded_songs() -func set_last_played_answered() -> void: - if song_object_array[latest_played_song].has_played && !song_object_array[latest_played_song].is_answered: - song_object_array[latest_played_song].is_answered = true func song_is_answered(song_no: int = currently_playing_song) -> void: - print("show_answer_pressed song_object: ", song_object_array[song_no]._to_string()) + # Nothing is playing yet (-1); a negative index would answer the last, unplayed song + if song_no < 0 || song_no >= song_object_array.size(): + return + GameLog.debug("show_answer_pressed song_object: ", song_object_array[song_no]._to_string()) song_object_array[song_no].is_answered = true if Settings.add_to_stats: - print("add to stats: ", song_no) - Settings.make_put_request("/music/played?song="+str(song_no)) + GameLog.debug("add to stats: ", song_no) + MusicServerClient.mark_song_played(song_no) + func add_to_playlist(new_song_object: SongObject) -> void: song_object_array.append(new_song_object) + func get_playlist() -> Array[SongObject]: if Settings.is_debug: return song_object_array @@ -55,45 +64,59 @@ func get_playlist() -> Array[SongObject]: temp_list.append(s) return temp_list + func reset_playlist() -> void: currently_playing_song = -1 latest_played_song = 0 clear_song_list() - + + func clear_song_list() -> void: song_object_array.clear() + func unset_is_playing() -> void: for song_object: SongObject in song_object_array: song_object.is_playing = false + func set_currently_playing_song(value: int) -> void: currently_playing_song = value + func get_current_soundtrack_title() -> String: return song_object_array[currently_playing_song].get_soundtrack_title() + func get_current_song_title() -> String: return song_object_array[currently_playing_song].get_song_title() -func add_point(player_id: int) -> void: + +func add_point(player_id: String) -> void: song_object_array[currently_playing_song].add_point(player_id) -func remove_point(player_id: int) -> void: + +func remove_point(player_id: String) -> void: song_object_array[currently_playing_song].remove_point(player_id) -func remove_player_from_list(player_to_remove: int) -> void: - for song_list_object: SongObject in song_object_array: - song_object_array[song_list_object.song_number].players_given_point.remove_at(song_object_array[song_list_object.song_number].players_given_point.find(player_to_remove)) + +## Removes a player's points from every song. +func remove_player_from_list(player_id: String) -> void: + for song_object: SongObject in song_object_array: + song_object.remove_point(player_id) + func get_song(song_no: int) -> AudioStream: return song_object_array[song_no].song + func add_song_to_song_object(song_no: int, sound: AudioStream) -> void: song_object_array[song_no].song = sound + func song_has_played(song_no: int = latest_played_song) -> void: song_object_array[song_no].has_played = true + func song_is_playing(song_no: int = latest_played_song) -> void: song_object_array[song_no].is_playing = true diff --git a/playlist.gd.uid b/data/playlist.gd.uid similarity index 100% rename from playlist.gd.uid rename to data/playlist.gd.uid diff --git a/data/settings.gd b/data/settings.gd new file mode 100644 index 0000000..cc3266a --- /dev/null +++ b/data/settings.gd @@ -0,0 +1,38 @@ +extends Node +## The game settings and the players, shared by the whole game. +## +## Registered as the [code]Settings[/code] autoload. + +const MAX_NUMBER_OF_PLAYERS: int = 7 + +var default_path: String = "https://music.sanplex.xyz" +var selected_server: int = 0 +var is_local: bool = false +var is_debug: bool = false +var stop_after_current: bool = true +var auto_repeat_song: bool = true +var hide_next_track: bool = true +var add_to_stats: bool = true +var use_low_played_mode: bool = false +var winning_score: int = 20 +var fullscreen: bool = false +var play_local: bool = false +var inspiration_list_speed: int = 1 +var hide_for_seconds: float = 5.0 +var hide_beginning: bool = false +var hide_length: bool = false +var hide_ticks: bool = false +var player_array: Array[PlayerObject] +var edit_players: bool = false +var currently_syncing: bool = false +var character_select_open: bool = false + +## The game version, set in Project Settings > Application > Config > Version. +## Releases are made from it, see RELEASING.md. +var version: String = ProjectSettings.get_setting("application/config/version", "") + + +func delete_children(node: Node) -> void: + for n: Node in node.get_children(): + node.remove_child(n) + n.queue_free() diff --git a/Settings.gd.uid b/data/settings.gd.uid similarity index 100% rename from Settings.gd.uid rename to data/settings.gd.uid diff --git a/SongObject.gd b/data/song_object.gd similarity index 58% rename from SongObject.gd rename to data/song_object.gd index 69b41bf..7500c90 100644 --- a/SongObject.gd +++ b/data/song_object.gd @@ -1,56 +1,59 @@ -extends Node +extends RefCounted class_name SongObject var song_number: int - var song_title: String - var soundtrack_title: String -var players_given_point: Array[int] = [] +## Ids of the players who got a point for this song. +var players_given_point: Array[String] = [] var is_answered: bool = false - var has_played: bool = false - var is_playing: bool = false - var song: AudioStream + func get_soundtrack_title() -> String: if is_answered: return soundtrack_title else: return "????????" + func get_song_title() -> String: if is_answered: return song_title else: return "??????" + func get_song_info() -> String: if Settings.is_debug: var format_string: String = "%d. %s - %s | played: %s | answered: %s" - return format_string % [(song_number + 1), soundtrack_title, song_title, has_played, is_answered] + return format_string % [(song_number + 1), soundtrack_title, song_title, has_played, + is_answered] elif is_answered: var format_string: String = "%d. %s - %s" return format_string % [(song_number + 1), soundtrack_title, song_title] else: return "??? - ???" -func add_point(id: int) -> void: - print("add_point") - players_given_point.append(id) - players_given_point.sort() -func remove_point(id: int) -> void: - print("remove_point") - if players_given_point.has(id): - players_given_point.remove_at(players_given_point.find(id)) - players_given_point.sort() +func add_point(player_id: String) -> void: + GameLog.debug("add_point") + if !players_given_point.has(player_id): + players_given_point.append(player_id) + + +func remove_point(player_id: String) -> void: + GameLog.debug("remove_point") + players_given_point.erase(player_id) + func _to_string() -> String: - var format_string: String = "song_number: %d | soundtrack_title: %s | song_title: %s | is_answered: %s | has_played: %s | is_playing: %s" - return format_string % [(song_number), soundtrack_title, song_title, is_answered, has_played, is_playing] + var format_string: String = "song_number: %d | soundtrack_title: %s | song_title: %s" \ + + " | is_answered: %s | has_played: %s | is_playing: %s" + return format_string % [song_number, soundtrack_title, song_title, is_answered, has_played, + is_playing] diff --git a/songObject.gd.uid b/data/song_object.gd.uid similarity index 100% rename from songObject.gd.uid rename to data/song_object.gd.uid diff --git a/default_theme.tres b/default_theme.tres deleted file mode 100644 index 8f931a5..0000000 --- a/default_theme.tres +++ /dev/null @@ -1,6 +0,0 @@ -[gd_resource type="Theme" load_steps=2 format=3 uid="uid://8bllart6xn1f"] - -[ext_resource type="StyleBox" uid="uid://bsv6yb0yir04l" path="res://style_box_flat.tres" id="1_uierc"] - -[resource] -PanelContainer/styles/panel = ExtResource("1_uierc") diff --git a/export_presets.cfg b/export_presets.cfg index f1c6df3..86ddd90 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -8,7 +8,7 @@ dedicated_server=false custom_features="" export_filter="all_resources" include_filter="" -exclude_filter="" +exclude_filter="tests/*, addons/gdUnit4/*, linux/*" export_path="../../MusicPlayer_1.5.1.exe" patches=PackedStringArray() encryption_include_filters="" @@ -83,7 +83,7 @@ dedicated_server=false custom_features="" export_filter="all_resources" include_filter="" -exclude_filter="" +exclude_filter="tests/*, addons/gdUnit4/*, linux/*" export_path="./MusicPlayer_0.9_Beta.dmg" patches=PackedStringArray() encryption_include_filters="" @@ -343,7 +343,7 @@ dedicated_server=false custom_features="" export_filter="all_resources" include_filter="" -exclude_filter="" +exclude_filter="tests/*, addons/gdUnit4/*, linux/*" export_path="web/index.html" patches=PackedStringArray() encryption_include_filters="" @@ -390,7 +390,7 @@ dedicated_server=false custom_features="" export_filter="all_resources" include_filter="" -exclude_filter="" +exclude_filter="tests/*, addons/gdUnit4/*, linux/*" export_path="./MusicPlayer_1.5.1.x86_64" patches=PackedStringArray() encryption_include_filters="" diff --git a/icon.svg b/icon.svg index adc26df..c60f5f1 100644 --- a/icon.svg +++ b/icon.svg @@ -1 +1,116 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icon.svg.import b/icon.svg.import index 187e4e3..b43d438 100644 --- a/icon.svg.import +++ b/icon.svg.import @@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.cte compress/mode=0 compress/high_quality=false compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 compress/hdr_compression=1 compress/normal_map=0 compress/channel_pack=0 @@ -25,6 +27,10 @@ mipmaps/generate=false mipmaps/limit=-1 roughness/mode=0 roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 process/fix_alpha_border=true process/premult_alpha=false process/normal_map_invert_y=false diff --git a/linux/install.sh b/linux/install.sh new file mode 100755 index 0000000..b249216 --- /dev/null +++ b/linux/install.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Installs Music Player Randomizer for the current user, with an app menu entry and icon. +# Run it from the unpacked release folder: ./install.sh + +set -euo pipefail +cd "$(dirname "$0")" + +bin_dir="${XDG_BIN_HOME:-$HOME/.local/bin}" +data_dir="${XDG_DATA_HOME:-$HOME/.local/share}" +binary="$bin_dir/music-player-randomizer" + +icon_dir="$data_dir/icons/hicolor/256x256/apps" +mkdir -p "$bin_dir" "$icon_dir" "$data_dir/applications" +install -m755 MusicPlayer.x86_64 "$binary" +install -m644 music-player-randomizer.png "$icon_dir/music-player-randomizer.png" +# The menu entry needs the full path, since ~/.local/bin is not always on PATH +sed "s|^Exec=.*|Exec=$binary|" music-player-randomizer.desktop \ + > "$data_dir/applications/music-player-randomizer.desktop" + +# Refresh menu and icon caches where the tools exist; most desktops also notice on their own +command -v update-desktop-database > /dev/null \ + && update-desktop-database "$data_dir/applications" || true +command -v gtk-update-icon-cache > /dev/null \ + && gtk-update-icon-cache -q "$data_dir/icons/hicolor" || true + +echo "Installed. Start it from your app menu, or run: $binary" +echo "To uninstall, delete:" +echo " $binary" +echo " $data_dir/applications/music-player-randomizer.desktop" +echo " $icon_dir/music-player-randomizer.png" diff --git a/linux/music-player-randomizer.desktop b/linux/music-player-randomizer.desktop new file mode 100644 index 0000000..911aa4d --- /dev/null +++ b/linux/music-player-randomizer.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=Music Player Randomizer +Comment=Guess the video game soundtrack with your friends +Exec=music-player-randomizer +Icon=music-player-randomizer +Terminal=false +Categories=Game;Audio; diff --git a/linux/package.sh b/linux/package.sh new file mode 100755 index 0000000..934c720 --- /dev/null +++ b/linux/package.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Packages an exported Linux build with its icon, app menu entry and install script. +# +# Usage: linux/package.sh +# Creates /MusicPlayer__linux.tar.gz. Godot is taken from $GODOT, +# or `godot` on PATH, to render the icon. + +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi +binary="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +version="$2" +out_dir="$(mkdir -p "$3" && cd "$3" && pwd)" +linux_dir="$(cd "$(dirname "$0")" && pwd)" +project_dir="$(dirname "$linux_dir")" +godot_bin="${GODOT:-godot}" + +staging="$(mktemp -d)" +trap 'rm -rf "$staging"' EXIT +package_dir="$staging/MusicPlayer" +mkdir -p "$package_dir" + +install -m755 "$binary" "$package_dir/MusicPlayer.x86_64" +install -m755 "$linux_dir/install.sh" "$package_dir/install.sh" +install -m644 "$linux_dir/music-player-randomizer.desktop" "$package_dir/" +# Run outside the project so Godot only renders the icon +(cd "$staging" && "$godot_bin" --headless -s "$linux_dir/render_icon.gd" -- \ + "$project_dir/icon.svg" "$package_dir/music-player-randomizer.png" 256) + +archive="$out_dir/MusicPlayer_${version}_linux.tar.gz" +tar -czf "$archive" -C "$staging" MusicPlayer +echo "Created $archive" diff --git a/linux/render_icon.gd b/linux/render_icon.gd new file mode 100644 index 0000000..de961c7 --- /dev/null +++ b/linux/render_icon.gd @@ -0,0 +1,24 @@ +extends SceneTree +## Renders an SVG to a square PNG, used to package the icon for Linux. +## +## Usage: godot --headless -s render_icon.gd -- + + +func _initialize() -> void: + var args: PackedStringArray = OS.get_cmdline_user_args() + if args.size() != 3: + printerr("Usage: godot --headless -s render_icon.gd -- ") + quit(1) + return + var svg: String = FileAccess.get_file_as_string(args[0]) + var source: Image = Image.new() + # Load at 1x to read the SVG's own size, then scale so the result is pixels + var error: int = source.load_svg_from_string(svg) + if error == OK: + var image: Image = Image.new() + error = image.load_svg_from_string(svg, float(args[2]) / source.get_width()) + if error == OK: + error = image.save_png(args[1]) + if error != OK: + printerr("Could not render %s to %s (error %d)" % [args[0], args[1], error]) + quit(error) diff --git a/linux/render_icon.gd.uid b/linux/render_icon.gd.uid new file mode 100644 index 0000000..ee4934c --- /dev/null +++ b/linux/render_icon.gd.uid @@ -0,0 +1 @@ +uid://b51oybupn7ns7 diff --git a/log.gd b/log.gd deleted file mode 100644 index 8f374b7..0000000 --- a/log.gd +++ /dev/null @@ -1,29 +0,0 @@ -extends Control - -@onready -var log_panel: Panel = %LogPanel - -@onready -var log_box: VBoxContainer = %LogVBoxContainer - -var log_rows: Array - -func add_log_row(text: String) -> void: - var log_row: String = get_time() + text - log_rows.append(log_row) - update_log() - -func update_log() -> void: - print("update_log") - Settings.delete_children(log_box) - for row: String in log_rows: - var log_label: Label = Label.new() - log_label.set_texture_filter(TextureFilter.TEXTURE_FILTER_NEAREST) - log_label.add_theme_font_size_override("font_size", 20) - log_label.text = row - log_label.autowrap_mode = TextServer.AUTOWRAP_WORD - log_box.add_child(log_label) - -func get_time() -> String: - var dateTime: Dictionary = Time.get_datetime_dict_from_system() - return "" + str(dateTime.hour) + ":" + str(dateTime.minute) + ":" + str(dateTime.second) + " " diff --git a/main/MainWindow.tscn b/main/MainWindow.tscn new file mode 100644 index 0000000..e00e9a9 --- /dev/null +++ b/main/MainWindow.tscn @@ -0,0 +1,110 @@ +[gd_scene format=3 uid="uid://xwq863o6uvsu"] + +[ext_resource type="PackedScene" path="res://main/TopBar.tscn" id="top_bar_scene"] +[ext_resource type="PackedScene" path="res://quiz/Quiz.tscn" id="quiz_scene"] +[ext_resource type="Script" uid="uid://b8f4g15cas2j2" path="res://main/main_window.gd" id="1_eu0t5"] +[ext_resource type="PackedScene" uid="uid://bxydgil1yifps" path="res://popups/SearchPopup.tscn" id="9_5ijvr"] +[ext_resource type="PackedScene" uid="uid://dpdem7pdxweb5" path="res://popups/SyncPopup.tscn" id="10_yxw0b"] +[ext_resource type="PackedScene" uid="uid://bd1by80q1v27e" path="res://popups/ShortcutsPopup.tscn" id="13_8eebo"] +[ext_resource type="PackedScene" uid="uid://btupbowehiyyu" path="res://popups/QRPopup.tscn" id="15_qr_window"] +[ext_resource type="PackedScene" uid="uid://cp54idaudb8dp" path="res://popups/AboutPopup.tscn" id="16_about_window"] +[ext_resource type="PackedScene" uid="uid://bijh5h5yrivm3" path="res://popups/LogPopup.tscn" id="14_26rwn"] + +[node name="MainWindow" type="Control" unique_id=407149696] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_eu0t5") + +[node name="Background" type="Panel" parent="." unique_id=1093825566] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_type_variation = &"WindowBackground" + +[node name="TopBar" parent="." unique_id=1734261695 instance=ExtResource("top_bar_scene")] + +[node name="Quiz" parent="." unique_id=1357908642 instance=ExtResource("quiz_scene")] + +[node name="SearchPopup" parent="." unique_id=1661250375 instance=ExtResource("9_5ijvr")] +visible = false +layout_mode = 1 +offset_left = 56.0 +offset_top = 96.0 +offset_right = 56.0 +offset_bottom = 96.0 + +[node name="SyncPopup" parent="." unique_id=182499699 instance=ExtResource("10_yxw0b")] +unique_name_in_owner = true +visible = false +layout_mode = 1 +offset_left = 210.0 +offset_top = 153.0 +offset_right = 210.0 +offset_bottom = 153.0 + +[node name="QRPopup" parent="." unique_id=589733821 instance=ExtResource("15_qr_window")] +unique_name_in_owner = true +visible = false +layout_mode = 1 +offset_left = 720.0 +offset_top = 153.0 +offset_right = 720.0 +offset_bottom = 153.0 + +[node name="ShortcutsPopup" parent="." unique_id=1513416242 instance=ExtResource("13_8eebo")] +unique_name_in_owner = true +visible = false +layout_mode = 1 +offset_left = 562.0 +offset_top = 160.0 +offset_right = 1262.0 +offset_bottom = 860.0 + +[node name="StatisticsPopupPanel" type="PopupPanel" parent="." unique_id=298311407] +oversampling_override = 1.0 +initial_position = 2 +size = Vector2i(450, 100) + +[node name="Label" type="Label" parent="StatisticsPopupPanel" unique_id=548316493] +offset_left = 4.0 +offset_top = 4.0 +offset_right = 446.0 +offset_bottom = 96.0 +text = "Statistics" +horizontal_alignment = 1 + +[node name="StatisticsLabel" type="Label" parent="StatisticsPopupPanel" unique_id=58226477] +unique_name_in_owner = true +offset_left = 4.0 +offset_top = 4.0 +offset_right = 446.0 +offset_bottom = 96.0 +text = "Total amount of soundtracks in the playlist: 9000" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="AboutPopup" parent="." unique_id=141261008 instance=ExtResource("16_about_window")] +unique_name_in_owner = true +visible = false +layout_mode = 1 +offset_left = 720.0 +offset_top = 153.0 +offset_right = 720.0 +offset_bottom = 153.0 + +[node name="LogPopup" parent="." unique_id=1289881388 instance=ExtResource("14_26rwn")] +unique_name_in_owner = true +visible = false +layout_mode = 1 +offset_left = 562.0 +offset_top = 132.0 +offset_right = 1262.0 +offset_bottom = 832.0 diff --git a/main/TopBar.tscn b/main/TopBar.tscn new file mode 100644 index 0000000..315e949 --- /dev/null +++ b/main/TopBar.tscn @@ -0,0 +1,96 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://main/top_bar.gd" id="top_bar_script"] + +[sub_resource type="InputEventKey" id="InputEventKey_ujjlu"] +device = -1 +alt_pressed = true +keycode = 83 + +[sub_resource type="Shortcut" id="Shortcut_fbju4"] +events = [SubResource("InputEventKey_ujjlu")] + +[node name="TopBar" type="Control" unique_id=1489162753] +layout_mode = 3 +anchors_preset = 10 +anchor_right = 1.0 +offset_bottom = 56.0 +grow_horizontal = 2 +mouse_filter = 2 +script = ExtResource("top_bar_script") + +[node name="TopPanelContainer" type="PanelContainer" parent="." unique_id=663404711] +layout_mode = 0 +offset_right = 1920.0 +offset_bottom = 56.0 + +[node name="Title" type="Label" parent="." unique_id=1187907861] +layout_mode = 0 +offset_left = 64.0 +offset_top = 12.0 +offset_right = 369.0 +offset_bottom = 50.0 +size_flags_horizontal = 0 +theme_type_variation = &"Title" +text = "Music Player Randomizer" + +[node name="SyncButton" type="Button" parent="." unique_id=1228313556] +layout_mode = 0 +offset_left = 1442.0 +offset_top = 8.0 +offset_right = 1512.0 +offset_bottom = 48.0 +focus_mode = 0 +action_mode = 0 +text = "Sync" + +[node name="SearchButton" type="Button" parent="." unique_id=1782453581] +layout_mode = 0 +offset_left = 1354.0 +offset_top = 8.0 +offset_right = 1424.0 +offset_bottom = 48.0 +focus_mode = 0 +action_mode = 0 +shortcut = SubResource("Shortcut_fbju4") +text = "Search" + +[node name="StatisticsButton" type="Button" parent="." unique_id=2045021269] +layout_mode = 0 +offset_left = 1530.0 +offset_top = 8.0 +offset_right = 1600.0 +offset_bottom = 48.0 +focus_mode = 0 +action_mode = 0 +text = "Statistics" + +[node name="ShortcutsButton" type="Button" parent="." unique_id=2066498226] +layout_mode = 0 +offset_left = 1626.0 +offset_top = 8.0 +offset_right = 1696.0 +offset_bottom = 48.0 +focus_mode = 0 +action_mode = 0 +text = "Shortcuts" + +[node name="AboutButton" type="Button" parent="." unique_id=1057035253] +layout_mode = 0 +offset_left = 1722.0 +offset_top = 8.0 +offset_right = 1792.0 +offset_bottom = 48.0 +focus_mode = 0 +action_mode = 0 +text = "About" + +[node name="QRButton" type="Button" parent="." unique_id=2021908559] +layout_mode = 0 +offset_left = 1802.0 +offset_top = 8.0 +offset_right = 1872.0 +offset_bottom = 48.0 +focus_mode = 0 +action_mode = 0 +text = "QR" diff --git a/main/main_window.gd b/main/main_window.gd new file mode 100644 index 0000000..bb44da9 --- /dev/null +++ b/main/main_window.gd @@ -0,0 +1,102 @@ +class_name MainWindow +extends Control +## The main window: the top bar, the popups opened from it and the quiz below it. +## +## Everything that is not part of a quiz lives here, so other views can be added next +## to the quiz later. + +## Dims the window behind an open popup and keeps clicks from reaching the buttons under it. +var popup_backdrop: PopupBackdrop + +@onready var top_bar: TopBar = $TopBar +@onready var quiz: Quiz = $Quiz +@onready var search_popup: SearchPopup = $SearchPopup +@onready var sync_popup: SyncPopup = %SyncPopup +@onready var shortcuts_popup: ShortcutsPopup = %ShortcutsPopup +@onready var qr_popup: QRPopup = %QRPopup +@onready var about_popup: AboutPopup = %AboutPopup +@onready var log_popup: LogPopup = %LogPopup +@onready var statistic_popup: PopupPanel = $StatisticsPopupPanel +@onready var statistic_label: Label = %StatisticsLabel + + +func _ready() -> void: + popup_backdrop = PopupBackdrop.add_to(self, _popups()) + if OS.has_feature("macos"): + GameLog.debug("is_mac") + top_bar.search_pressed.connect(show_search) + top_bar.sync_pressed.connect(sync_soundtracks) + top_bar.statistics_pressed.connect(get_statistics) + top_bar.shortcuts_pressed.connect(show_shortcuts) + top_bar.about_pressed.connect(show_about) + top_bar.qr_pressed.connect(show_qr) + + sync_popup.sync_started.connect(_on_sync_started) + sync_popup.sync_finished.connect(_on_sync_finished) + quiz.server_changed.connect(search_popup.get_list_of_soundtracks) + quiz.event_logged.connect(log_popup.add_log_row) + + search_popup.get_list_of_soundtracks() + + +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed: + if event.alt_pressed && event.keycode == KEY_ENTER: + GameLog.debug("Alt + Enter pressed") + Settings.fullscreen = !Settings.fullscreen + if Settings.fullscreen: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) + if !Settings.fullscreen: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + if event.alt_pressed && event.keycode == KEY_K: + shortcuts_popup.toggle() + if event.alt_pressed && event.keycode == KEY_L: + log_popup.toggle() + + +func _popups() -> Array[BasePopup]: + return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup] + + +func show_search() -> void: + GameLog.debug("show_search") + search_popup.toggle() + + +func show_about() -> void: + GameLog.debug("show_about") + about_popup.open() + + +func show_shortcuts() -> void: + GameLog.debug("show_shortcuts") + shortcuts_popup.open() + + +func show_qr() -> void: + GameLog.debug("show_qr") + qr_popup.open() + + +func get_statistics() -> void: + GameLog.debug("get_statistics") + statistic_popup.visible = true + statistic_label.text = "Total amount of soundtracks in the playlist: " \ + + str(search_popup.soundtracks.size()) + + +func sync_soundtracks() -> void: + GameLog.debug("sync_soundtracks") + sync_popup.start_sync() + + +func _on_sync_started() -> void: + GameLog.debug("_on_sync_started") + top_bar.sync_button.disabled = false + quiz.lock_for_sync() + + +func _on_sync_finished() -> void: + GameLog.debug("_on_sync_finished") + search_popup.get_list_of_soundtracks() + quiz.on_sync_finished() diff --git a/MainWindow.gd.uid b/main/main_window.gd.uid similarity index 100% rename from MainWindow.gd.uid rename to main/main_window.gd.uid diff --git a/main/top_bar.gd b/main/top_bar.gd new file mode 100644 index 0000000..81b79b0 --- /dev/null +++ b/main/top_bar.gd @@ -0,0 +1,22 @@ +class_name TopBar +extends Control +## The bar along the top of the main window, with the title and the buttons for things +## that are not part of a quiz. + +signal search_pressed +signal sync_pressed +signal statistics_pressed +signal shortcuts_pressed +signal about_pressed +signal qr_pressed + +@onready var sync_button: Button = $SyncButton + + +func _ready() -> void: + $SearchButton.pressed.connect(search_pressed.emit) + sync_button.pressed.connect(sync_pressed.emit) + $StatisticsButton.pressed.connect(statistics_pressed.emit) + $ShortcutsButton.pressed.connect(shortcuts_pressed.emit) + $AboutButton.pressed.connect(about_pressed.emit) + $QRButton.pressed.connect(qr_pressed.emit) diff --git a/main/top_bar.gd.uid b/main/top_bar.gd.uid new file mode 100644 index 0000000..0ba76b4 --- /dev/null +++ b/main/top_bar.gd.uid @@ -0,0 +1 @@ +uid://bb2ukabiec1vv diff --git a/neutral_gray.tres b/neutral_gray.tres deleted file mode 100644 index ae64844..0000000 --- a/neutral_gray.tres +++ /dev/null @@ -1,4 +0,0 @@ -[gd_resource type="StyleBoxFlat" format=3 uid="uid://blf3g88mugovr"] - -[resource] -bg_color = Color(0.257813, 0.257813, 0.257813, 1) diff --git a/new_character_select.gd b/new_character_select.gd deleted file mode 100644 index a7cbe94..0000000 --- a/new_character_select.gd +++ /dev/null @@ -1,90 +0,0 @@ -extends Control - -@onready -var character_panel: Panel = %CharacterPanel - -@onready -var title_label: Label = %TitleLabel - -@onready -var character_grid: GridContainer = %CharacterGridContainer - -@onready -var close_timer: Timer = %CloseTimer - -var can_select_character: bool = false - -signal character_selected(file_name: String) -signal new_new_character_selected(file_name: String) - -var character: PackedScene = preload("res://character.tscn") - -func _ready() -> void: - close_timer.timeout.connect(_on_close_timeout) - -func select_character(file_name: String) -> void: - print("select_character") - character_selected.emit(file_name) - -func _on_close_timeout() -> void: - print("_on_close_timeout!") - Settings.character_select_open = false - close_timer.stop() - -func load_characters() -> void: - print("load_characters") - var fetch_character_list: Callable = func fetch_character_list(list) -> void: - print("fetch_character_list") - Settings.delete_children(character_grid) - if typeof(list) == TYPE_ARRAY: - for character_name: String in list: - print("character_name ", character_name) - var character_box: Character = character.instantiate() - character_grid.add_child(character_box) - character_box.custom_minimum_size = Vector2(235, 145) - character_box.size = Vector2(235, 145) - character_box.set_character_name(character_name) - character_box.connect("new_character_selected", _on_character_selected) - Settings.make_request2("/characters", fetch_character_list, true) - -func show_grid(player_name: String) -> void: - print("show_grid") - title_label.text = "Select character for " + player_name - close_timer.stop() - self.visible = true - can_select_character = true - Settings.character_select_open = true - -func _on_character_selected(texture: ImageTexture) -> void: - print("_on_character_selected") - self.visible = false - if can_select_character: - new_new_character_selected.emit(texture) - close_timer.start() - can_select_character = false - -func _input(event: InputEvent) -> void: - if self.visible == true: - if event is InputEventMouseButton && event.is_pressed(): - var evLocal: InputEvent = make_input_local(event) - if !Rect2(Vector2(0, 0), character_panel.size).has_point(evLocal.position): - self.visible = false - Settings.character_select_open = false - -func load_characters_local() -> void: - var characters: DirAccess = DirAccess.open("res://characters/") - if characters: - characters.list_dir_begin() - var file_name: String = characters.get_next() - while file_name != "": - file_name = file_name.replace('.import', '') # <--- remove the .import - if file_name.ends_with(".png"): - var texture: Resource = load("res://characters/" + file_name) - var texture_btn: TextureButton = TextureButton.new() - character_grid.add_child(texture_btn) - texture_btn.custom_minimum_size = Vector2(80, 40) - texture_btn.ignore_texture_size = true - texture_btn.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT - texture_btn.texture_normal = texture - texture_btn.pressed.connect(select_character.bind(file_name)) - file_name = characters.get_next() diff --git a/MusicPlayer.tscn b/player/MusicPlayer.tscn similarity index 62% rename from MusicPlayer.tscn rename to player/MusicPlayer.tscn index 94955e7..72c4ee5 100644 --- a/MusicPlayer.tscn +++ b/player/MusicPlayer.tscn @@ -1,10 +1,10 @@ [gd_scene load_steps=10 format=3 uid="uid://ds15cgsf8vpvc"] -[ext_resource type="Script" uid="uid://d1v0rquma6nt8" path="res://MusicPlayer.gd" id="1_t24ra"] +[ext_resource type="Script" uid="uid://d1v0rquma6nt8" path="res://player/music_player.gd" id="1_t24ra"] [ext_resource type="AudioStream" uid="uid://n2g8jddr85h2" path="res://sounds/01. Opening.mp3" id="2_xti80"] [ext_resource type="Texture2D" uid="uid://comxqfiykp54f" path="res://icons/play_icon_light.svg" id="3_6g308"] [ext_resource type="Texture2D" uid="uid://ccb6rvbldlgdg" path="res://icons/reload_light_icon.svg" id="4_jleuo"] -[ext_resource type="PackedScene" uid="uid://w400rnew7453" path="res://volume_slider.tscn" id="5_iifuj"] +[ext_resource type="PackedScene" uid="uid://w400rnew7453" path="res://player/VolumeSlider.tscn" id="5_iifuj"] [sub_resource type="InputEventKey" id="InputEventKey_r4qo3"] device = -1 @@ -26,19 +26,11 @@ unicode = 90 [sub_resource type="Shortcut" id="Shortcut_i7swq"] events = [SubResource("InputEventKey_2nvce")] -[node name="MusicPlayer" type="PanelContainer"] -anchors_preset = 8 -anchor_left = 0.5 -anchor_top = 0.5 -anchor_right = 0.5 -anchor_bottom = 0.5 -offset_left = -121.0 -offset_top = -15.5 -offset_right = 121.0 -offset_bottom = 16.5 -grow_horizontal = 2 -grow_vertical = 2 -size_flags_vertical = 4 +[node name="MusicPlayer" type="Control"] +layout_mode = 3 +offset_right = 1792.0 +offset_bottom = 71.0 +mouse_filter = 2 script = ExtResource("1_t24ra") [node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="."] @@ -53,11 +45,18 @@ bus = &"music" stream = ExtResource("2_xti80") bus = &"music" -[node name="MusicPlayerContainer" type="HBoxContainer" parent="."] +[node name="PlayerBar" type="PanelContainer" parent="."] +layout_mode = 0 +offset_left = 1.0 +offset_right = 1792.0 +offset_bottom = 32.0 + +[node name="MusicPlayerContainer" type="HBoxContainer" parent="PlayerBar"] layout_mode = 2 size_flags_vertical = 4 -[node name="PlayTextureButton" type="TextureButton" parent="MusicPlayerContainer"] +[node name="PlayTextureButton" type="TextureButton" parent="PlayerBar/MusicPlayerContainer"] +unique_name_in_owner = true custom_minimum_size = Vector2(32, 32) layout_mode = 2 focus_mode = 0 @@ -67,7 +66,8 @@ texture_normal = ExtResource("3_6g308") ignore_texture_size = true stretch_mode = 0 -[node name="RestartTextureButton" type="TextureButton" parent="MusicPlayerContainer"] +[node name="RestartTextureButton" type="TextureButton" parent="PlayerBar/MusicPlayerContainer"] +unique_name_in_owner = true custom_minimum_size = Vector2(32, 32) layout_mode = 2 focus_mode = 0 @@ -78,17 +78,30 @@ ignore_texture_size = true stretch_mode = 0 flip_h = true -[node name="MusicPlayerSlider" type="HSlider" parent="MusicPlayerContainer"] +[node name="MusicPlayerSlider" type="HSlider" parent="PlayerBar/MusicPlayerContainer"] +unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 size_flags_vertical = 4 scrollable = false -[node name="MusicTimeLabel" type="Label" parent="MusicPlayerContainer"] +[node name="MusicTimeLabel" type="Label" parent="PlayerBar/MusicPlayerContainer"] +unique_name_in_owner = true layout_mode = 2 text = "0:00 / 0:00" -[node name="VolumeSlider" parent="MusicPlayerContainer" instance=ExtResource("5_iifuj")] +[node name="VolumeSlider" parent="PlayerBar/MusicPlayerContainer" instance=ExtResource("5_iifuj")] +unique_name_in_owner = true custom_minimum_size = Vector2(100, 0) layout_mode = 2 size_flags_vertical = 4 + +[node name="RepeatSongCheckButton" type="CheckButton" parent="."] +layout_mode = 0 +offset_top = 40.0 +offset_right = 144.0 +offset_bottom = 71.0 +focus_mode = 0 +button_pressed = true +action_mode = 0 +text = "Auto repeat" diff --git a/volume_slider.tscn b/player/VolumeSlider.tscn similarity index 86% rename from volume_slider.tscn rename to player/VolumeSlider.tscn index 51ee424..69efbea 100644 --- a/volume_slider.tscn +++ b/player/VolumeSlider.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=2 format=3 uid="uid://w400rnew7453"] -[ext_resource type="Script" uid="uid://bgviu7smansj1" path="res://volume_slider.gd" id="1_1aufb"] +[ext_resource type="Script" uid="uid://bgviu7smansj1" path="res://player/volume_slider.gd" id="1_1aufb"] [node name="VolumeSlider" type="HSlider"] offset_right = 8.0 diff --git a/MusicPlayer.gd b/player/music_player.gd similarity index 79% rename from MusicPlayer.gd rename to player/music_player.gd index 16062ef..dffc0cc 100644 --- a/MusicPlayer.gd +++ b/player/music_player.gd @@ -1,74 +1,63 @@ class_name MusicPlayer -extends PanelContainer +extends Control +## The bar that plays the songs, with play, restart, progress and volume, and the +## auto repeat switch under it. -# LOC 20/1-24: 136 -# LOC 12/6-24: 170 - -@onready -var play_button: TextureButton = $MusicPlayerContainer/PlayTextureButton - -@onready -var restart_button: TextureButton = $MusicPlayerContainer/RestartTextureButton - -@onready -var audio_player: AudioStreamPlayer = $AudioStreamPlayer - -@onready -var sound_player: AudioStreamPlayer = $AudioSoundPlayer - -@onready -var sound_effect_player: AudioStreamPlayer = $AudioSoundEffectPlayer - -@onready -var progress_slider: HSlider = $MusicPlayerContainer/MusicPlayerSlider - -@onready -var volume_slider: HSlider = $MusicPlayerContainer/VolumeSlider - -@onready -var music_time_label: Label = $MusicPlayerContainer/MusicTimeLabel - -@onready -var path: String = '/Users/sebastian/ResilioSync/Sorterat_test/Metal Gear Solid 4 - Guns of the Patriots/2-16 Metal Gear Saga.mp3' +signal update_song_list +signal play_next_song var play_icon: Texture = preload("res://icons/play_icon_light.svg") var pause_icon: Texture = preload("res://icons/pause_icon_light.svg") - var songs: Array = [] - var is_changing: bool = false var playback_position: float var stream: AudioStream var song_finished: bool = false -signal update_song_list -signal play_next_song +@onready var play_button: TextureButton = %PlayTextureButton +@onready var restart_button: TextureButton = %RestartTextureButton +@onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer +@onready var sound_player: AudioStreamPlayer = $AudioSoundPlayer +@onready var sound_effect_player: AudioStreamPlayer = $AudioSoundEffectPlayer +@onready var progress_slider: HSlider = %MusicPlayerSlider +@onready var volume_slider: VolumeSlider = %VolumeSlider +@onready var music_time_label: Label = %MusicTimeLabel +@onready var auto_repeat_button: CheckButton = $RepeatSongCheckButton + -# Called when the node enters the scene tree for the first time. func _ready() -> void: play_button.pressed.connect(play_or_pause) restart_button.pressed.connect(restart) progress_slider.drag_started.connect(_on_drag_started) progress_slider.drag_ended.connect(_on_drag_ended) - + audio_player.finished.connect(_on_finished) + auto_repeat_button.pressed.connect(_on_auto_repeat_pressed) + + +func _on_auto_repeat_pressed() -> void: + Settings.auto_repeat_song = !Settings.auto_repeat_song + func _process(_delta: float) -> void: if audio_player.has_stream_playback() && !is_changing && !audio_player.stream_paused: - if (!Settings.hide_beginning || audio_player.get_playback_position() > Settings.hide_for_seconds): + if !Settings.hide_beginning \ + || audio_player.get_playback_position() > Settings.hide_for_seconds: progress_slider.value = audio_player.get_playback_position() if stream != null: music_time_label.text = format_text(progress_slider.value, stream.get_length()) else: music_time_label.text = format_text(progress_slider.value, 0.0) + func format_time(time: float) -> String: - var mins: String = "%02d" % floor(time / 60) - var sec: String = "%02d" % round(fmod(time, 60)) - return mins + ":" + sec + var total_seconds: int = roundi(time) + @warning_ignore("integer_division") + return "%02d:%02d" % [total_seconds / 60, total_seconds % 60] + func format_text(part: float, total: float) -> String: - if (Settings.hide_beginning && part <= Settings.hide_for_seconds) && Settings.hide_length: + if (Settings.hide_beginning && part < Settings.hide_for_seconds) && Settings.hide_length: return "??:?? / ??:??" elif Settings.hide_length: return format_time(part) + " / ??:??" @@ -77,6 +66,7 @@ func format_text(part: float, total: float) -> String: else: return format_time(part) + " / " + format_time(total) + func play_or_pause() -> void: if song_finished: restart() @@ -85,7 +75,7 @@ func play_or_pause() -> void: audio_player.stream_paused = false song_finished = false audio_player.seek(playback_position) - print("continue") + GameLog.debug("continue") progress_slider.max_value = round(stream.get_length()) if Settings.hide_ticks: progress_slider.tick_count = 0 @@ -94,11 +84,13 @@ func play_or_pause() -> void: else: pause() + func pause() -> void: audio_player.stream_paused = true playback_position = audio_player.get_playback_position() play_button.texture_normal = play_icon + func restart() -> void: audio_player.stop() audio_player.stream_paused = false @@ -108,7 +100,8 @@ func restart() -> void: audio_player.seek(playback_position) play_button.texture_normal = pause_icon audio_player.play() - + + func _on_finished() -> void: play_button.texture_normal = play_icon song_finished = true @@ -116,26 +109,31 @@ func _on_finished() -> void: play_next_song.emit() if Settings.auto_repeat_song: restart() - + + func _on_drag_started() -> void: is_changing = true + func _on_drag_ended(_changed: bool) -> void: audio_player.seek(progress_slider.value) playback_position = progress_slider.value is_changing = false + func seek(new_position: float) -> void: progress_slider.value += new_position is_changing = true audio_player.seek(progress_slider.value) playback_position = progress_slider.value is_changing = false - + + func change_volume(value: float) -> void: volume_slider.value += value volume_slider.change_volume(volume_slider.value) + func _on_point_triggered(point: String) -> void: if point == "first": var value: int = randi_range(0, 10) @@ -147,11 +145,13 @@ func _on_point_triggered(point: String) -> void: play_sound(preload("res://sounds/intro_short.mp3")) elif point == "match": play_sound(preload("res://sounds/sound0.mp3")) - + + func play_sound_effect(sound_effect_name: AudioStream) -> void: sound_effect_player.stream = sound_effect_name sound_effect_player.play() + func play_sound(sound_name: AudioStream) -> void: sound_player.stream = sound_name audio_player.stop() @@ -159,9 +159,8 @@ func play_sound(sound_name: AudioStream) -> void: song_finished = true play_button.texture_normal = play_icon progress_slider.value = 0 - #if stream != null: - # music_time_label.text = format_text(progress_slider.value, stream.get_length()) - + + func play_song(song: AudioStream) -> void: audio_player.stream = song audio_player.play() @@ -177,8 +176,9 @@ func play_song(song: AudioStream) -> void: progress_slider.tick_count = round(stream.get_length() / 60) music_time_label.text = format_text(progress_slider.value, stream.get_length()) + func play_song_object(song_object_no: int) -> void: - print("play_song_object") + GameLog.debug("play_song_object") if audio_player.is_playing(): audio_player.stop() await get_tree().create_timer(0.5).timeout @@ -198,34 +198,38 @@ func play_song_object(song_object_no: int) -> void: Playlist.set_currently_playing_song(song_object_no) Playlist.unset_is_playing() if !Settings.hide_next_track: - print("Show answer now!!") + GameLog.debug("Show answer now!!") Playlist.song_is_answered(song_object_no) Playlist.song_has_played(song_object_no) Playlist.song_is_playing(song_object_no) update_song_list.emit() + func get_sound_test_song() -> void: var test_sound: Callable = func test_sound(body: PackedByteArray) -> void: var sound: AudioStream = AudioStreamMP3.new() sound.data = body play_song(sound) - Settings.make_request2("/music/soundTest", test_sound, true) + MusicServerClient.fetch_sound_test_song(test_sound) ##### LOCAL var local_path: String = '/Users/sebastian/ResilioSync/Sorterat_test/Metal Gear Solid 4 - Guns of the Patriots/2-16 Metal Gear Saga.mp3' + func load_mp3(_path: String) -> AudioStream: var file: FileAccess = FileAccess.open(_path, FileAccess.READ) var sound: AudioStream = AudioStreamMP3.new() sound.data = file.get_buffer(file.get_length()) return sound + func play_local_song() -> void: if songs: local_path = songs[0] - print(local_path) - print(FileAccess.file_exists(local_path)) + GameLog.debug(local_path) + GameLog.debug(FileAccess.file_exists(local_path)) play_sound(load_mp3(local_path)) + func sound_test_local() -> void: play_sound(preload("res://sounds/01. Opening.mp3")) diff --git a/MusicPlayer.gd.uid b/player/music_player.gd.uid similarity index 100% rename from MusicPlayer.gd.uid rename to player/music_player.gd.uid diff --git a/volume_slider.gd b/player/volume_slider.gd similarity index 58% rename from volume_slider.gd rename to player/volume_slider.gd index 3e8751e..05f2138 100644 --- a/volume_slider.gd +++ b/player/volume_slider.gd @@ -1,18 +1,24 @@ +class_name VolumeSlider extends HSlider +## A slider that sets the volume of an audio bus. -@export -var audio_bus_name := "music" +## The audio bus whose volume the slider sets. -@onready -var _bus: int +@export var audio_bus_name: String = "music" -func _ready(): +var _bus: int + + +func _ready() -> void: _bus = AudioServer.get_bus_index(audio_bus_name) value_changed.connect(_on_value_changed) value = db_to_linear(AudioServer.get_bus_volume_db(_bus)) -func _on_value_changed(changed_value: float) -> void: - AudioServer.set_bus_volume_db(_bus, linear_to_db(changed_value)) +## Sets the bus volume to [param changed_value], from 0 (silent) to 1 (full). func change_volume(changed_value: float) -> void: AudioServer.set_bus_volume_db(_bus, linear_to_db(changed_value)) + + +func _on_value_changed(changed_value: float) -> void: + AudioServer.set_bus_volume_db(_bus, linear_to_db(changed_value)) diff --git a/volume_slider.gd.uid b/player/volume_slider.gd.uid similarity index 100% rename from volume_slider.gd.uid rename to player/volume_slider.gd.uid diff --git a/AboutWindow.tscn b/popups/AboutPopup.tscn similarity index 84% rename from AboutWindow.tscn rename to popups/AboutPopup.tscn index b746c05..d410293 100644 --- a/AboutWindow.tscn +++ b/popups/AboutPopup.tscn @@ -1,8 +1,8 @@ [gd_scene format=3 uid="uid://cp54idaudb8dp"] -[ext_resource type="Script" uid="uid://b7bh0of7olbcb" path="res://AboutWindow.gd" id="1_about_script"] +[ext_resource type="Script" uid="uid://b7bh0of7olbcb" path="res://popups/about_popup.gd" id="1_about_script"] -[node name="AboutControl" type="Control" unique_id=1626010355] +[node name="AboutPopup" type="Control" unique_id=1626010355 node_paths=PackedStringArray("panel")] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 @@ -10,15 +10,16 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_about_script") +panel = NodePath("AboutPanel") [node name="AboutPanel" type="Panel" parent="." unique_id=1907299275] unique_name_in_owner = true layout_mode = 0 offset_right = 600.0 offset_bottom = 640.0 +theme_type_variation = &"PopupBackground" [node name="TitleLabel" type="Label" parent="AboutPanel" unique_id=200295721] -texture_filter = 1 layout_mode = 0 offset_right = 600.0 offset_bottom = 50.0 @@ -28,10 +29,11 @@ vertical_alignment = 1 [node name="ScrollContainer" type="ScrollContainer" parent="AboutPanel" unique_id=417677913] layout_mode = 0 -offset_left = 10.0 +offset_left = 20.0 offset_top = 50.0 -offset_right = 590.0 +offset_right = 580.0 offset_bottom = 630.0 +horizontal_scroll_mode = 0 [node name="ContentVBox" type="VBoxContainer" parent="AboutPanel/ScrollContainer" unique_id=1746337912] layout_mode = 2 @@ -40,14 +42,11 @@ size_flags_vertical = 3 [node name="VersionLabel" type="Label" parent="AboutPanel/ScrollContainer/ContentVBox" unique_id=352884126] unique_name_in_owner = true -texture_filter = 1 layout_mode = 2 size_flags_horizontal = 3 horizontal_alignment = 1 [node name="DescriptionLabel" type="Label" parent="AboutPanel/ScrollContainer/ContentVBox" unique_id=543094263] -texture_filter = 1 -custom_minimum_size = Vector2(560, 0) layout_mode = 2 size_flags_horizontal = 3 text = "Try your video game music knowledge with this VGM randomizer, invite your friends and see who is the best." @@ -56,8 +55,6 @@ autowrap_mode = 2 [node name="WhatsNewLabel" type="Label" parent="AboutPanel/ScrollContainer/ContentVBox" unique_id=1363040291] unique_name_in_owner = true -texture_filter = 1 -custom_minimum_size = Vector2(560, 0) layout_mode = 2 size_flags_horizontal = 3 autowrap_mode = 2 diff --git a/character.tscn b/popups/CharacterCard.tscn similarity index 79% rename from character.tscn rename to popups/CharacterCard.tscn index 7bc0cc3..e9b1f16 100644 --- a/character.tscn +++ b/popups/CharacterCard.tscn @@ -1,12 +1,9 @@ -[gd_scene load_steps=4 format=3 uid="uid://c7ecjj2af6usf"] +[gd_scene format=3 uid="uid://c7ecjj2af6usf"] -[ext_resource type="Script" uid="uid://i35b3scbpwpu" path="res://character.gd" id="1_pjt0s"] +[ext_resource type="Script" uid="uid://i35b3scbpwpu" path="res://popups/character_card.gd" id="1_pjt0s"] [ext_resource type="Texture2D" uid="uid://cv3hdd6bsruau" path="res://icon.svg" id="2_pjt0s"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjt0s"] -bg_color = Color(0.302, 0.302, 0.302, 1) - -[node name="Control" type="Control"] +[node name="CharacterCard" type="Control"] layout_mode = 3 anchors_preset = 0 script = ExtResource("1_pjt0s") @@ -15,7 +12,7 @@ script = ExtResource("1_pjt0s") layout_mode = 0 offset_right = 240.0 offset_bottom = 145.0 -theme_override_styles/panel = SubResource("StyleBoxFlat_pjt0s") +theme_type_variation = &"CharacterCard" [node name="MarginContainer" type="MarginContainer" parent="Panel"] layout_mode = 0 diff --git a/new_character_select.tscn b/popups/CharacterSelectPopup.tscn similarity index 81% rename from new_character_select.tscn rename to popups/CharacterSelectPopup.tscn index 038b505..239b5b5 100644 --- a/new_character_select.tscn +++ b/popups/CharacterSelectPopup.tscn @@ -1,24 +1,23 @@ -[gd_scene load_steps=3 format=3 uid="uid://cdy4kvemwaiom"] +[gd_scene format=3 uid="uid://cdy4kvemwaiom"] -[ext_resource type="Script" uid="uid://cvffg87wr6r3y" path="res://new_character_select.gd" id="1_h4tdp"] +[ext_resource type="Script" uid="uid://cvffg87wr6r3y" path="res://popups/character_select_popup.gd" id="1_h4tdp"] -[sub_resource type="LabelSettings" id="LabelSettings_p4iny"] -font_size = 25 - -[node name="Control" type="Control"] +[node name="CharacterSelectPopup" type="Control" node_paths=PackedStringArray("panel")] layout_mode = 3 anchors_preset = 0 script = ExtResource("1_h4tdp") +panel = NodePath("CharacterPanel") [node name="CharacterPanel" type="Panel" parent="."] unique_name_in_owner = true layout_mode = 0 -offset_right = 1260.0 +offset_right = 1290.0 offset_bottom = 665.0 +theme_type_variation = &"PopupBackground" [node name="VBoxContainer" type="VBoxContainer" parent="CharacterPanel"] layout_mode = 0 -offset_right = 1263.0 +offset_right = 1290.0 offset_bottom = 659.0 size_flags_horizontal = 3 @@ -26,8 +25,8 @@ size_flags_horizontal = 3 unique_name_in_owner = true layout_mode = 2 size_flags_vertical = 0 +theme_type_variation = &"Title" text = "Character Select" -label_settings = SubResource("LabelSettings_p4iny") horizontal_alignment = 1 [node name="MarginContainer" type="MarginContainer" parent="CharacterPanel/VBoxContainer"] diff --git a/Log.tscn b/popups/LogPopup.tscn similarity index 52% rename from Log.tscn rename to popups/LogPopup.tscn index 8d0d540..abf0d07 100644 --- a/Log.tscn +++ b/popups/LogPopup.tscn @@ -1,8 +1,8 @@ -[gd_scene load_steps=2 format=3 uid="uid://bijh5h5yrivm3"] +[gd_scene format=3 uid="uid://bijh5h5yrivm3"] -[ext_resource type="Script" uid="uid://cw41y87l64qo7" path="res://log.gd" id="1_q72ua"] +[ext_resource type="Script" uid="uid://cw41y87l64qo7" path="res://popups/log_popup.gd" id="1_q72ua"] -[node name="Control" type="Control"] +[node name="LogPopup" type="Control" node_paths=PackedStringArray("panel")] custom_minimum_size = Vector2(700, 700) layout_mode = 3 anchors_preset = 15 @@ -11,23 +11,36 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_q72ua") +panel = NodePath("LogPanel") [node name="LogPanel" type="Panel" parent="."] unique_name_in_owner = true visibility_layer = 513 custom_minimum_size = Vector2(700, 700) layout_mode = 0 -offset_right = 40.0 -offset_bottom = 40.0 +offset_right = 700.0 +offset_bottom = 700.0 +theme_type_variation = &"PopupBackground" + +[node name="TitleLabel" type="Label" parent="LogPanel"] +layout_mode = 0 +offset_top = 8.0 +offset_right = 700.0 +offset_bottom = 48.0 +theme_type_variation = &"Title" +text = "Log" +horizontal_alignment = 1 [node name="LogScrollContainer" type="ScrollContainer" parent="LogPanel"] -custom_minimum_size = Vector2(700, 700) layout_mode = 0 -offset_right = 40.0 -offset_bottom = 40.0 +offset_left = 20.0 +offset_top = 56.0 +offset_right = 680.0 +offset_bottom = 684.0 +horizontal_scroll_mode = 0 [node name="LogVBoxContainer" type="VBoxContainer" parent="LogPanel/LogScrollContainer"] unique_name_in_owner = true -custom_minimum_size = Vector2(700, 700) layout_mode = 2 +size_flags_horizontal = 3 theme_override_constants/separation = 1 diff --git a/QRWindow.tscn b/popups/QRPopup.tscn similarity index 85% rename from QRWindow.tscn rename to popups/QRPopup.tscn index 34ff866..0914d46 100644 --- a/QRWindow.tscn +++ b/popups/QRPopup.tscn @@ -1,9 +1,9 @@ [gd_scene format=3 uid="uid://btupbowehiyyu"] -[ext_resource type="Script" uid="uid://bsa6xg5xos6h7" path="res://QRWindow.gd" id="1_qr_script"] +[ext_resource type="Script" uid="uid://bsa6xg5xos6h7" path="res://popups/qr_popup.gd" id="1_qr_script"] [ext_resource type="Script" uid="uid://dynulhnyq5hpq" path="res://addons/kenyoni/qr_code/qr_code_rect.gd" id="2_jpsxt"] -[node name="QRControl" type="Control" unique_id=1996957585] +[node name="QRPopup" type="Control" unique_id=1996957585 node_paths=PackedStringArray("panel")] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 @@ -11,14 +11,17 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_qr_script") +panel = NodePath("QRPanel") [node name="QRPanel" type="Panel" parent="." unique_id=1021365843] unique_name_in_owner = true layout_mode = 0 offset_right = 450.0 offset_bottom = 520.0 +theme_type_variation = &"PopupBackground" [node name="QRCodeRect" type="TextureRect" parent="QRPanel" unique_id=1054378438] +unique_name_in_owner = true layout_mode = 0 offset_left = 20.0 offset_top = 20.0 diff --git a/SearchWindow.tscn b/popups/SearchPopup.tscn similarity index 78% rename from SearchWindow.tscn rename to popups/SearchPopup.tscn index 02b3fef..e946f36 100644 --- a/SearchWindow.tscn +++ b/popups/SearchPopup.tscn @@ -1,8 +1,8 @@ [gd_scene format=3 uid="uid://bxydgil1yifps"] -[ext_resource type="Script" uid="uid://bg5yo08638600" path="res://SearchWindow.gd" id="1_m7bij"] +[ext_resource type="Script" uid="uid://bg5yo08638600" path="res://popups/search_popup.gd" id="1_m7bij"] -[node name="Control" type="Control" unique_id=616238617] +[node name="SearchPopup" type="Control" unique_id=616238617 node_paths=PackedStringArray("panel")] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 @@ -10,15 +10,16 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_m7bij") +panel = NodePath("SearchPanel") [node name="SearchPanel" type="Panel" parent="." unique_id=1262945994] unique_name_in_owner = true layout_mode = 0 offset_right = 1152.0 offset_bottom = 640.0 +theme_type_variation = &"PopupBackground" [node name="ClearButton" type="Button" parent="." unique_id=1549439398] -texture_filter = 1 layout_mode = 0 offset_left = 936.0 offset_top = 16.0 @@ -30,7 +31,6 @@ action_mode = 0 text = "Clear" [node name="Searchbar" type="TextEdit" parent="." unique_id=2112669372] -texture_filter = 1 layout_mode = 0 offset_left = 32.0 offset_top = 16.0 @@ -46,6 +46,7 @@ offset_right = 576.0 offset_bottom = 384.0 scale = Vector2(2, 2) -[node name="VBoxContainer" type="VBoxContainer" parent="ScrollContainer" unique_id=1567403144] +[node name="SearchResults" type="VBoxContainer" parent="ScrollContainer" unique_id=1567403144] +unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 diff --git a/SettingsWindow.tscn b/popups/SettingsPopup.tscn similarity index 89% rename from SettingsWindow.tscn rename to popups/SettingsPopup.tscn index 8271407..c1fb029 100644 --- a/SettingsWindow.tscn +++ b/popups/SettingsPopup.tscn @@ -1,11 +1,8 @@ [gd_scene format=3 uid="uid://dldpeo5y3l5hq"] -[ext_resource type="Script" uid="uid://dukqyjoduf1af" path="res://SettingsWindow.gd" id="1_bt55j"] +[ext_resource type="Script" uid="uid://dukqyjoduf1af" path="res://popups/settings_popup.gd" id="1_bt55j"] -[sub_resource type="LabelSettings" id="LabelSettings_3xrlm"] -font_size = 25 - -[node name="SettingsControl" type="Control" unique_id=1298840328] +[node name="SettingsPopup" type="Control" unique_id=1298840328 node_paths=PackedStringArray("panel")] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 @@ -13,17 +10,22 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_bt55j") +panel = NodePath("SettingsPanel") [node name="SettingsPanel" type="Panel" parent="." unique_id=45537159] unique_name_in_owner = true layout_mode = 0 offset_right = 500.0 offset_bottom = 640.0 +theme_type_variation = &"PopupBackground" [node name="ScrollContainer" type="ScrollContainer" parent="SettingsPanel" unique_id=1325795358] layout_mode = 0 -offset_right = 500.0 -offset_bottom = 640.0 +offset_left = 20.0 +offset_top = 8.0 +offset_right = 480.0 +offset_bottom = 632.0 +horizontal_scroll_mode = 0 [node name="VBoxContainer" type="VBoxContainer" parent="SettingsPanel/ScrollContainer" unique_id=1796635163] clip_contents = true @@ -33,14 +35,14 @@ size_flags_horizontal = 3 [node name="Label" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1111531402] layout_mode = 2 size_flags_vertical = 0 +theme_type_variation = &"Title" text = "Settings" -label_settings = SubResource("LabelSettings_3xrlm") horizontal_alignment = 1 [node name="StopAfterCurrentCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=508305575] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 0 +size_flags_horizontal = 3 button_pressed = true action_mode = 0 text = "Stop after current" @@ -48,7 +50,7 @@ text = "Stop after current" [node name="HideNextTrackCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1568244599] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 0 +size_flags_horizontal = 3 button_pressed = true action_mode = 0 text = "Hide next track" @@ -56,21 +58,21 @@ text = "Hide next track" [node name="AddToDatabaseCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1673750542] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 0 +size_flags_horizontal = 3 action_mode = 0 text = "Turn on played to database" [node name="LowPlayedCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1672657048] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 0 +size_flags_horizontal = 3 action_mode = 0 text = "Use low played mode" [node name="HideBeginningCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=1370617867] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 0 +size_flags_horizontal = 3 action_mode = 0 text = "Hide beginning of songs" @@ -81,12 +83,15 @@ layout_mode = 2 [node name="HideForSecondsTitle" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer" unique_id=1822369383] unique_name_in_owner = true layout_mode = 2 +size_flags_horizontal = 3 text = "Hide Start Seconds: " [node name="HideForSecondsLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer" unique_id=1580108945] unique_name_in_owner = true +custom_minimum_size = Vector2(48, 0) layout_mode = 2 text = "5.0" +horizontal_alignment = 1 [node name="LowerHideForSecondsButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer" unique_id=180643442] unique_name_in_owner = true @@ -107,14 +112,14 @@ text = "+1" [node name="HideLengthCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=67806372] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 0 +size_flags_horizontal = 3 action_mode = 0 text = "Hide length of songs" [node name="HideTicksCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=706322752] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 0 +size_flags_horizontal = 3 action_mode = 0 text = "Hide ticks in playbar" @@ -123,12 +128,15 @@ layout_mode = 2 [node name="Label2" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer" unique_id=1892907487] layout_mode = 2 +size_flags_horizontal = 3 text = "Winning Score: " [node name="ScoreLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer" unique_id=444542662] unique_name_in_owner = true +custom_minimum_size = Vector2(48, 0) layout_mode = 2 text = "20" +horizontal_alignment = 1 [node name="LowerButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer" unique_id=614806355] unique_name_in_owner = true @@ -151,12 +159,15 @@ layout_mode = 2 [node name="CacheTitleLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer" unique_id=365012193] layout_mode = 2 +size_flags_horizontal = 3 text = "Predownload songs: " [node name="CacheLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer" unique_id=1558370172] unique_name_in_owner = true +custom_minimum_size = Vector2(48, 0) layout_mode = 2 text = "1" +horizontal_alignment = 1 [node name="LowerCacheButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer" unique_id=1990385038] unique_name_in_owner = true @@ -179,12 +190,15 @@ layout_mode = 2 [node name="InspirationListSpeedTitle" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer" unique_id=1397861506] layout_mode = 2 +size_flags_horizontal = 3 text = "Inspiration List Speed: " [node name="InspirationListSpeedLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer" unique_id=867387668] unique_name_in_owner = true +custom_minimum_size = Vector2(48, 0) layout_mode = 2 text = "1" +horizontal_alignment = 1 [node name="LowerInspirationSpeedButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer" unique_id=845209534] unique_name_in_owner = true @@ -221,13 +235,13 @@ popup/item_4/id = 5 [node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=400304684] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 0 +size_flags_horizontal = 3 text = "Fullscreen" [node name="QuickSyncButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer" unique_id=49497448] visible = false layout_mode = 2 -size_flags_horizontal = 0 +size_flags_horizontal = 3 button_pressed = true action_mode = 0 text = "Quick sync" @@ -236,6 +250,6 @@ text = "Quick sync" unique_name_in_owner = true visible = false layout_mode = 2 -size_flags_horizontal = 0 +size_flags_horizontal = 3 disabled = true text = "Local" diff --git a/shortcutsWindow.tscn b/popups/ShortcutsPopup.tscn similarity index 83% rename from shortcutsWindow.tscn rename to popups/ShortcutsPopup.tscn index 11ddc5f..180dd54 100644 --- a/shortcutsWindow.tscn +++ b/popups/ShortcutsPopup.tscn @@ -1,8 +1,8 @@ -[gd_scene load_steps=2 format=3 uid="uid://bd1by80q1v27e"] +[gd_scene format=3 uid="uid://bd1by80q1v27e"] -[ext_resource type="Script" uid="uid://rolypd0cqd6h" path="res://shortcuts_window.gd" id="1_12pxi"] +[ext_resource type="Script" uid="uid://rolypd0cqd6h" path="res://popups/shortcuts_popup.gd" id="1_12pxi"] -[node name="ShorcutsControl" type="Control"] +[node name="ShortcutsPopup" type="Control" node_paths=PackedStringArray("panel")] custom_minimum_size = Vector2(700, 700) layout_mode = 3 anchors_preset = 15 @@ -11,6 +11,7 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_12pxi") +panel = NodePath("ShortcutsPanel") [node name="ShortcutsPanel" type="Panel" parent="."] unique_name_in_owner = true @@ -18,6 +19,7 @@ custom_minimum_size = Vector2(700, 700) layout_mode = 0 offset_right = 40.0 offset_bottom = 40.0 +theme_type_variation = &"PopupBackground" [node name="ScrollContainer" type="ScrollContainer" parent="ShortcutsPanel"] custom_minimum_size = Vector2(700, 700) diff --git a/SyncWindow.tscn b/popups/SyncPopup.tscn similarity index 84% rename from SyncWindow.tscn rename to popups/SyncPopup.tscn index 747fb45..b7804d4 100644 --- a/SyncWindow.tscn +++ b/popups/SyncPopup.tscn @@ -1,11 +1,8 @@ [gd_scene format=3 uid="uid://dpdem7pdxweb5"] -[ext_resource type="Script" uid="uid://c3ejukwth512a" path="res://sync_window.gd" id="1_8ba47"] +[ext_resource type="Script" uid="uid://c3ejukwth512a" path="res://popups/sync_popup.gd" id="1_8ba47"] -[sub_resource type="LabelSettings" id="LabelSettings_anbfi"] -font_size = 25 - -[node name="Control2" type="Control" unique_id=1302914356] +[node name="SyncPopup" type="Control" unique_id=1302914356 node_paths=PackedStringArray("panel")] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 @@ -13,28 +10,34 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_8ba47") +panel = NodePath("SyncPanel") [node name="SyncPanel" type="Panel" parent="." unique_id=1714634204] unique_name_in_owner = true layout_mode = 0 -offset_right = 1152.0 +offset_right = 1500.0 offset_bottom = 640.0 +theme_type_variation = &"PopupBackground" [node name="ScrollContainer" type="ScrollContainer" parent="SyncPanel" unique_id=1289116349] layout_mode = 0 -offset_right = 1152.0 -offset_bottom = 640.0 +offset_left = 20.0 +offset_top = 8.0 +offset_right = 1480.0 +offset_bottom = 632.0 +horizontal_scroll_mode = 0 [node name="SyncVBoxContainer" type="VBoxContainer" parent="SyncPanel/ScrollContainer" unique_id=1594735567] layout_mode = 2 -size_flags_horizontal = 6 +size_flags_horizontal = 3 +theme_override_constants/separation = 12 [node name="SyncTitleLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer" unique_id=2125198054] unique_name_in_owner = true layout_mode = 2 size_flags_vertical = 0 +theme_type_variation = &"Title" text = "Sync" -label_settings = SubResource("LabelSettings_anbfi") horizontal_alignment = 1 [node name="SyncTime" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer" unique_id=1344333200] @@ -48,8 +51,8 @@ unique_name_in_owner = true visible = false layout_mode = 2 size_flags_vertical = 0 +theme_type_variation = &"Title" text = "No changes since last sync" -label_settings = SubResource("LabelSettings_anbfi") horizontal_alignment = 1 [node name="ProgressHBoxContainer" type="HBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer" unique_id=196414805] @@ -59,7 +62,7 @@ layout_mode = 2 [node name="SyncProgressBar" type="ProgressBar" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer" unique_id=719135231] unique_name_in_owner = true -custom_minimum_size = Vector2(1000, 0) +custom_minimum_size = Vector2(1300, 0) layout_mode = 2 size_flags_horizontal = 6 step = 1.0 @@ -69,17 +72,13 @@ rounded = true unique_name_in_owner = true visible = false layout_mode = 2 -size_flags_horizontal = 6 -theme_override_constants/separation = 30 - -[node name="VSeparator6" type="VSeparator" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer" unique_id=1432793432] -unique_name_in_owner = true -layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 [node name="SoundtracksAddedVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer" unique_id=897546696] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 6 +size_flags_horizontal = 3 [node name="SoundtracksAddedLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox" unique_id=535797768] layout_mode = 2 @@ -93,7 +92,7 @@ layout_mode = 2 [node name="SoundtracksReAddedVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer" unique_id=995736806] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 6 +size_flags_horizontal = 3 [node name="SoundtracksReAddedLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox" unique_id=1142636525] layout_mode = 2 @@ -106,7 +105,7 @@ layout_mode = 2 [node name="SoundtracksChangedTitleVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer" unique_id=294212033] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 6 +size_flags_horizontal = 3 [node name="SoundtracksChangedTitleLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox" unique_id=696960827] layout_mode = 2 @@ -119,7 +118,7 @@ layout_mode = 2 [node name="SoundtracksChangedContentVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer" unique_id=1470922281] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 6 +size_flags_horizontal = 3 [node name="SoundtracksChangedContentLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox" unique_id=1209639099] layout_mode = 2 @@ -132,7 +131,7 @@ layout_mode = 2 [node name="SoundtracksRemovedVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer" unique_id=1174594338] unique_name_in_owner = true layout_mode = 2 -size_flags_horizontal = 6 +size_flags_horizontal = 3 [node name="SoundtracksRemovedLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox" unique_id=877713862] layout_mode = 2 @@ -142,12 +141,13 @@ text = "SoundtracksRemoved" unique_name_in_owner = true layout_mode = 2 -[node name="CatchedErrorsVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer" unique_id=63733711] +[node name="CaughtErrorsVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer" unique_id=63733711] unique_name_in_owner = true visible = false layout_mode = 2 +size_flags_horizontal = 3 -[node name="CatchedErrorsLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/CatchedErrorsVBox" unique_id=1784389644] +[node name="CaughtErrorsLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox" unique_id=1784389644] layout_mode = 2 text = "CatchedErrors" horizontal_alignment = 1 diff --git a/WinnerWindow.tscn b/popups/WinnerPopup.tscn similarity index 84% rename from WinnerWindow.tscn rename to popups/WinnerPopup.tscn index c7a9415..4c02b63 100644 --- a/WinnerWindow.tscn +++ b/popups/WinnerPopup.tscn @@ -1,12 +1,9 @@ [gd_scene format=3 uid="uid://cxlsmxnrp0agh"] -[ext_resource type="Script" uid="uid://dxpb22xbwf3t1" path="res://winner_window.gd" id="1_winner_script"] +[ext_resource type="Script" uid="uid://dxpb22xbwf3t1" path="res://popups/winner_popup.gd" id="1_winner_script"] [ext_resource type="Texture2D" uid="uid://r4as0nmtoa7p" path="res://noCharacter.png" id="2_no_character"] -[sub_resource type="LabelSettings" id="LabelSettings_hr75l"] -font_size = 35 - -[node name="WinnerControl" type="Control" unique_id=1111111111] +[node name="WinnerPopup" type="Control" unique_id=1111111111 node_paths=PackedStringArray("panel")] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 @@ -14,12 +11,14 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_winner_script") +panel = NodePath("WinnerPanel") [node name="WinnerPanel" type="Panel" parent="." unique_id=89212869] unique_name_in_owner = true layout_mode = 0 offset_right = 500.0 offset_bottom = 600.0 +theme_type_variation = &"PopupBackground" [node name="VBoxContainer" type="VBoxContainer" parent="WinnerPanel" unique_id=471159349] layout_mode = 0 @@ -40,10 +39,9 @@ stretch_mode = 5 [node name="WinnerLabel" type="Label" parent="WinnerPanel/VBoxContainer" unique_id=1437597286] unique_name_in_owner = true -texture_filter = 1 layout_mode = 2 +theme_type_variation = &"TitleMedium" text = "Winner!" -label_settings = SubResource("LabelSettings_hr75l") horizontal_alignment = 1 vertical_alignment = 2 diff --git a/AboutWindow.gd b/popups/about_popup.gd similarity index 65% rename from AboutWindow.gd rename to popups/about_popup.gd index ef74e11..d6ee581 100644 --- a/AboutWindow.gd +++ b/popups/about_popup.gd @@ -1,18 +1,22 @@ -extends Control +class_name AboutPopup +extends BasePopup +## Shows the game version and what is new in each version. -@onready -var about_panel: Panel = %AboutPanel - -@onready -var version_label: Label = %VersionLabel - -@onready -var whats_new_label: Label = %WhatsNewLabel - - - -# Changelog as Dictionary with version as key and description as value +## What is new in each version, newest first. const WHATS_NEW: Dictionary = { + "2.0.0": "Reworked all popups, they dim the background and close when clicking outside + Fixed blurry text and the layout of the player list + Moved Sync to the top row and Settings next to the reset buttons + The inspiration and sync lists now show where each item starts + The sync result now looks right with any number of columns + Failed song downloads are now retried instead of getting stuck + The game no longer waits forever when the server does not answer + The song cache now fills up to the cache size in the settings + Statistics are turned back on when a new game starts + Fixed bugs with low winning scores and Show answer before a song is playing + The log now shows the time on every row + New app icon and a menu entry on Linux + Reorganized the code and added automated tests", "1.9.0": "Made improvements to the Search window Made small changes to the inspiration list scrolling Made small changes to the player list @@ -50,27 +54,19 @@ const WHATS_NEW: Dictionary = { "0.7-Beta": "Can now hop between songs" } - -func _input(event: InputEvent) -> void: - if self.visible == true: - if event is InputEventMouseButton && event.is_pressed(): - var evLocal: InputEvent = make_input_local(event) - if !Rect2(Vector2(0, 0), about_panel.size).has_point(evLocal.position): - self.visible = false +@onready var about_panel: Panel = %AboutPanel +@onready var version_label: Label = %VersionLabel +@onready var whats_new_label: Label = %WhatsNewLabel -func show_window() -> void: - visible = true +func open() -> void: + super() version_label.text = Settings.version - + # Build changelog text with version headers var changelog_text: String = "" for version: String in WHATS_NEW.keys(): changelog_text += "\n" + version + "\n" changelog_text += WHATS_NEW[version] + "\n" - + whats_new_label.text = changelog_text - - -func hide_window() -> void: - visible = false diff --git a/AboutWindow.gd.uid b/popups/about_popup.gd.uid similarity index 100% rename from AboutWindow.gd.uid rename to popups/about_popup.gd.uid diff --git a/popups/base_popup.gd b/popups/base_popup.gd new file mode 100644 index 0000000..813c32d --- /dev/null +++ b/popups/base_popup.gd @@ -0,0 +1,38 @@ +class_name BasePopup +extends Control +## A window shown on top of the main window, such as Settings or About. +## +## Clicking outside [member panel] closes it. Popups that need to prepare or clean up +## override [method open] or [method close] and call [code]super()[/code]. + +## The visible part of the popup. Clicks outside it close the popup. + +@export var panel: Control + + +## Shows the popup. +func open() -> void: + visible = true + + +## Hides the popup. +func close() -> void: + visible = false + + +## Opens the popup if it is closed and closes it if it is open. +func toggle() -> void: + if visible: + close() + else: + open() + + +func _input(event: InputEvent) -> void: + if !visible || !(event is InputEventMouseButton) || !event.pressed: + return + var click: InputEventMouseButton = panel.make_input_local(event) + if !Rect2(Vector2.ZERO, panel.size).has_point(click.position): + close() + # The click only closes the popup; it must not also press what is behind it + get_viewport().set_input_as_handled() diff --git a/popups/base_popup.gd.uid b/popups/base_popup.gd.uid new file mode 100644 index 0000000..1c572c6 --- /dev/null +++ b/popups/base_popup.gd.uid @@ -0,0 +1 @@ +uid://dwd4dcbujpv6p diff --git a/character.gd b/popups/character_card.gd similarity index 52% rename from character.gd rename to popups/character_card.gd index 3cde46b..4c31b0b 100644 --- a/character.gd +++ b/popups/character_card.gd @@ -1,31 +1,41 @@ -class_name Character +class_name CharacterCard extends Control +## One character in the character select popup: its picture and name. Clicking it picks +## the character. -@onready -var panel: Panel = $Panel - -@onready -var character_image: TextureRect = %CharacterTextureRect - -@onready -var character_name_label: Label = %CharacterName +## Emitted with the character's picture when the card is clicked. signal new_character_selected(img_texture: ImageTexture) +@onready var panel: Panel = $Panel +@onready var character_image: TextureRect = %CharacterTextureRect +@onready var character_name_label: Label = %CharacterName + + +func _input(event: InputEvent) -> void: + if visible && Settings.character_select_open: + if event is InputEventMouseButton && event.is_pressed() \ + && event.get_button_index() == MOUSE_BUTTON_LEFT: + var local_event: InputEvent = make_input_local(event) + if Rect2(Vector2(0, 0), panel.size).has_point(local_event.position): + GameLog.debug(character_name_label.text) + new_character_selected.emit(character_image.texture) + + +## Shows [param character_name] and fetches its picture from the server. func set_character_name(character_name: String) -> void: panel.size = Vector2(240, 145) - #margin.size = Vector2(240, 145) - #margin set margin 15, 10, 15, 0 character_name_label.text = character_name.split(".")[0] character_name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER var image_fetched: Callable = func image_fetched(image: PackedByteArray) -> void: var img: Image = Image.new() var err: Error = img.load_png_from_buffer(image) if err != OK: - print(err) + GameLog.debug("Character image is not a PNG, trying JPG: ", error_string(err)) var err2: Error = img.load_jpg_from_buffer(image) if err2 != OK: - print(err2) + GameLog.warning("Could not load the image for ", character_name, ": ", + error_string(err2)) var new_image: ImageTexture = ImageTexture.new() new_image.set_image(img) character_image.custom_minimum_size = Vector2(200, 100) @@ -35,12 +45,4 @@ func set_character_name(character_name: String) -> void: character_image.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL character_image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED character_image.texture = new_image - Settings.make_request2("/character?name=" + character_name.uri_encode(), image_fetched, true) - -func _input(event: InputEvent) -> void: - if self.visible == true && Settings.character_select_open == true: - if event is InputEventMouseButton && event.is_pressed() && event.get_button_index() == MOUSE_BUTTON_LEFT: - var evLocal: InputEvent = make_input_local(event) - if Rect2(Vector2(0, 0), panel.size).has_point(evLocal.position): - print(character_name_label.text) - new_character_selected.emit(character_image.texture) + MusicServerClient.fetch_character_image(character_name, image_fetched) diff --git a/character.gd.uid b/popups/character_card.gd.uid similarity index 100% rename from character.gd.uid rename to popups/character_card.gd.uid diff --git a/popups/character_select_popup.gd b/popups/character_select_popup.gd new file mode 100644 index 0000000..7b1b250 --- /dev/null +++ b/popups/character_select_popup.gd @@ -0,0 +1,65 @@ +class_name CharacterSelectPopup +extends BasePopup + +## Emitted with the picture of the character that was picked. +signal character_selected(texture: ImageTexture) + +var can_select_character: bool = false +var character: PackedScene = preload("res://popups/CharacterCard.tscn") + +@onready var character_panel: Panel = %CharacterPanel +@onready var title_label: Label = %TitleLabel +@onready var character_grid: GridContainer = %CharacterGridContainer +@onready var close_timer: Timer = %CloseTimer + + +func _ready() -> void: + close_timer.timeout.connect(_on_close_timeout) + + +func _on_close_timeout() -> void: + GameLog.debug("_on_close_timeout!") + Settings.character_select_open = false + close_timer.stop() + + +func load_characters() -> void: + GameLog.debug("load_characters") + var fetch_character_list: Callable = func fetch_character_list(list: Variant) -> void: + GameLog.debug("fetch_character_list") + Settings.delete_children(character_grid) + if typeof(list) == TYPE_ARRAY: + for character_name: String in list: + GameLog.debug("character_name ", character_name) + var character_box: CharacterCard = character.instantiate() + character_grid.add_child(character_box) + character_box.custom_minimum_size = Vector2(235, 145) + character_box.size = Vector2(235, 145) + character_box.set_character_name(character_name) + character_box.new_character_selected.connect(_on_character_selected) + MusicServerClient.fetch_character_list(fetch_character_list) + + +func show_grid(player_name: String) -> void: + GameLog.debug("show_grid") + title_label.text = "Select character for " + player_name + close_timer.stop() + open() + can_select_character = true + Settings.character_select_open = true + + +func close() -> void: + super() + Settings.character_select_open = false + + +func _on_character_selected(texture: ImageTexture) -> void: + GameLog.debug("_on_character_selected") + # Not close(): character_select_open stays true until close_timer fires, so the click + # that picked the character does not also select a song behind it + visible = false + if can_select_character: + character_selected.emit(texture) + close_timer.start() + can_select_character = false diff --git a/new_character_select.gd.uid b/popups/character_select_popup.gd.uid similarity index 100% rename from new_character_select.gd.uid rename to popups/character_select_popup.gd.uid diff --git a/popups/log_popup.gd b/popups/log_popup.gd new file mode 100644 index 0000000..68e22a4 --- /dev/null +++ b/popups/log_popup.gd @@ -0,0 +1,28 @@ +class_name LogPopup +extends BasePopup +## The game log: a row with the time for each thing that happens in the quiz, such as a +## player getting a point. Opened with Alt+L. + +var log_rows: Array + +@onready var log_panel: Panel = %LogPanel +@onready var log_box: VBoxContainer = %LogVBoxContainer + + +func add_log_row(text: String) -> void: + var log_row: String = get_time() + text + log_rows.append(log_row) + log_box.add_child(make_log_label(log_row)) + + +func make_log_label(row: String) -> Label: + var log_label: Label = Label.new() + log_label.theme_type_variation = &"TextLarge" + log_label.text = row + log_label.autowrap_mode = TextServer.AUTOWRAP_WORD + return log_label + + +func get_time() -> String: + var time: Dictionary = Time.get_time_dict_from_system() + return "%02d:%02d:%02d " % [time.hour, time.minute, time.second] diff --git a/log.gd.uid b/popups/log_popup.gd.uid similarity index 100% rename from log.gd.uid rename to popups/log_popup.gd.uid diff --git a/popups/popup_backdrop.gd b/popups/popup_backdrop.gd new file mode 100644 index 0000000..a83ccf0 --- /dev/null +++ b/popups/popup_backdrop.gd @@ -0,0 +1,31 @@ +class_name PopupBackdrop +extends ColorRect +## Dims everything behind an open popup and keeps clicks from reaching the buttons +## under it. +## +## The backdrop sits above the rest of its parent and below the popups it belongs to. +## Popups close themselves when clicked outside, and the backdrop keeps that click from +## reaching the buttons behind them. + +var _popups: Array[BasePopup] = [] + + +## Adds a backdrop to [param parent] and moves [param popups] above it. The backdrop +## shows while any of them is open. +static func add_to(parent: Control, popups: Array[BasePopup]) -> PopupBackdrop: + var backdrop: PopupBackdrop = PopupBackdrop.new() + backdrop.name = "PopupBackdrop" + backdrop.mouse_filter = Control.MOUSE_FILTER_STOP + backdrop._popups = popups + parent.add_child(backdrop) + backdrop.color = backdrop.get_theme_color(&"backdrop", &"Palette") + backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + for popup: BasePopup in popups: + parent.move_child(popup, -1) + popup.visibility_changed.connect(backdrop._update) + backdrop._update() + return backdrop + + +func _update() -> void: + visible = _popups.any(func(popup: BasePopup) -> bool: return popup.visible) diff --git a/popups/popup_backdrop.gd.uid b/popups/popup_backdrop.gd.uid new file mode 100644 index 0000000..d9963a1 --- /dev/null +++ b/popups/popup_backdrop.gd.uid @@ -0,0 +1 @@ +uid://swjhekc8beob diff --git a/popups/qr_popup.gd b/popups/qr_popup.gd new file mode 100644 index 0000000..42b3f26 --- /dev/null +++ b/popups/qr_popup.gd @@ -0,0 +1,29 @@ +class_name QRPopup +extends BasePopup +## Shows a QR code with the address of the search page on the MusicServer, so players +## can search for soundtracks on their phones. + +@onready var qr_panel: Panel = $QRPanel +@onready var qr_code_rect: QRCodeRect = %QRCodeRect +@onready var url_label: Label = %URLLabel + + +func _ready() -> void: + qr_code_rect.light_module_color = Color.WHITE + qr_code_rect.dark_module_color = Color.BLACK + qr_code_rect.quiet_zone_size = 4 + + # Generate QR code for the search URL + var url: String = Settings.default_path + "/search" + qr_code_rect.data = url + url_label.text = qr_code_rect.data + GameLog.debug("QR code generated for: ", url) + + +func open() -> void: + super() + # Regenerate QR code in case settings changed + var url: String = Settings.default_path + "/search" + qr_code_rect.data = url + url_label.text = qr_code_rect.data + diff --git a/QRWindow.gd.uid b/popups/qr_popup.gd.uid similarity index 100% rename from QRWindow.gd.uid rename to popups/qr_popup.gd.uid diff --git a/SearchWindow.gd b/popups/search_popup.gd similarity index 57% rename from SearchWindow.gd rename to popups/search_popup.gd index 497307c..27bd6c1 100644 --- a/SearchWindow.gd +++ b/popups/search_popup.gd @@ -1,64 +1,65 @@ -extends Control - -@onready -var search_list: VBoxContainer = $ScrollContainer/VBoxContainer - -@onready -var search_panel: Panel = %SearchPanel - -@onready -var clear_button: Button = $ClearButton - -@onready -var search_bar: TextEdit = $Searchbar +class_name SearchPopup +extends BasePopup +## Searches every soundtrack on the server as you type. Matches ignore case, spaces and +## special characters, and also find the letters in order with others in between. var soundtracks: Array = [] -# Precomputed soundtrack data: array of dicts with original, lowercase, and cleaned fields +## Each soundtrack as a Dictionary with its original, lowercase and cleaned name. var precomputed_soundtracks: Array = [] -# Regex cache: maps cleaned search terms to compiled RegEx objects +## Compiled patterns for the letters-in-order search, by cleaned search term. var regex_cache: Dictionary = {} var label_pool: Array[Label] = [] -# Called when the node enters the scene tree for the first time. +@onready var search_list: VBoxContainer = %SearchResults +@onready var search_panel: Panel = %SearchPanel +@onready var clear_button: Button = $ClearButton +@onready var search_bar: TextEdit = $Searchbar + + func _ready() -> void: clear_button.pressed.connect(clear) search_bar.grab_focus() search_bar.text_changed.connect(search) visibility_changed.connect(focus) + func focus() -> void: - if self.visible == true: + if visible: search_bar.grab_focus() clear() + func close() -> void: clear() - self.visible = false + super() + func search() -> void: - print(search_bar.text) + GameLog.debug(search_bar.text) var search_text: String = search_bar.text - + if search_text == "": show_all_soundtracks() return - + perform_search(search_text) + func show_all_soundtracks() -> void: # Hide all visible labels and add to pool for child: Label in search_list.get_children(): if child.visible: child.visible = false label_pool.append(child) - + # Show all soundtracks in order for soundtrack: String in soundtracks: add_soundtrack_reuse(soundtrack) + func perform_search(search_text: String) -> void: # Hide all visible labels and add to pool for child: Label in search_list.get_children(): @@ -66,63 +67,76 @@ func perform_search(search_text: String) -> void: child.visible = false label_pool.append(child) + for soundtrack: String in find_matches(search_text): + add_soundtrack_reuse(soundtrack) + + +## Returns the soundtracks matching [param search_text], best matches first: plain +## case-insensitive matches, then matches ignoring spaces and special characters, +## then matches with the letters in order but not next to each other. +func find_matches(search_text: String) -> Array[String]: var seen: Dictionary = {} var search_text_lower: String = search_text.to_lower() var clean_search_text: String = clean_term(search_text) - # Collect all matches first, then display in order - var matches: Array = [] + var matches: Array[String] = [] # Pass 1: Exact case-insensitive contains match on lowercase versions - for i in range(precomputed_soundtracks.size()): - var st_data = precomputed_soundtracks[i] + for i: int in range(precomputed_soundtracks.size()): + var st_data: Dictionary = precomputed_soundtracks[i] if st_data["lowercase"].contains(search_text_lower): if not st_data["original"] in seen: seen[st_data["original"]] = true matches.append(st_data["original"]) # Pass 2: Contains match on cleaned terms - for i in range(precomputed_soundtracks.size()): - var st_data = precomputed_soundtracks[i] + for i: int in range(precomputed_soundtracks.size()): + var st_data: Dictionary = precomputed_soundtracks[i] if st_data["cleaned"].contains(clean_search_text): if not st_data["original"] in seen: seen[st_data["original"]] = true matches.append(st_data["original"]) # Pass 3: Regex match on cleaned terms - var regex = compile_regex_cached(clean_search_text) - for i in range(precomputed_soundtracks.size()): - var st_data = precomputed_soundtracks[i] + var regex: RegEx = compile_regex_cached(clean_search_text) + for i: int in range(precomputed_soundtracks.size()): + var st_data: Dictionary = precomputed_soundtracks[i] if regex.search(st_data["cleaned"]): if not st_data["original"] in seen: seen[st_data["original"]] = true matches.append(st_data["original"]) - # Display all matches in the order they appear in soundtracks - for soundtrack: String in matches: - add_soundtrack_reuse(soundtrack) + return matches -# Cleans a term by removing spaces and replacing special characters. -# Matches MusicServer's clean_term() function. + +## Cleans a term by removing spaces and replacing special characters. +## Matches MusicServer's clean_term() function. func clean_term(term: String) -> String: - return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and").replace("'n", "and").to_lower() + return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and") \ + .replace("'n", "and").to_lower() -# Compiles a regex pattern with caching to avoid recompiling the same pattern multiple times. + +## Compiles the letters-in-order pattern for [param search_term], once per term. func compile_regex_cached(search_term: String) -> RegEx: if not search_term in regex_cache: - var regex = RegEx.new() - var regText: String = ".*" + var regex: RegEx = RegEx.new() + var regex_text: String = ".*" for letter: String in search_term: - regText += letter + ".*" - regex.compile(regText) + # Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work + if "\\^$.|?*+()[]{}".contains(letter): + regex_text += "\\" + regex_text += letter + ".*" + regex.compile(regex_text) regex_cache[search_term] = regex return regex_cache[search_term] + func add_soundtrack(soundtrack: String) -> void: # Backward compatibility - delegates to reuse version add_soundtrack_reuse(soundtrack) -# Optimized version that reuses label nodes from pool instead of creating new ones + +## Shows [param soundtrack], reusing a hidden label from the pool when there is one. func add_soundtrack_reuse(soundtrack: String) -> void: var label: Label if label_pool.size() > 0: @@ -138,6 +152,7 @@ func add_soundtrack_reuse(soundtrack: String) -> void: search_list.add_child(label) label.visible = true + func check_if_soundtrack_exists(soundtrack: String) -> bool: var soundtrack_exists: bool = false for child: Label in search_list.get_children(): @@ -145,37 +160,35 @@ func check_if_soundtrack_exists(soundtrack: String) -> bool: soundtrack_exists = true return soundtrack_exists + func get_list_of_soundtracks() -> void: - print("get_list_of_soundtracks") - var handle_soundtracks: Callable = func handle_soundtracks(array) -> void: + GameLog.debug("get_list_of_soundtracks") + var handle_soundtracks: Callable = func handle_soundtracks(array: Variant) -> void: if typeof(array) == TYPE_ARRAY: - soundtracks = [] - precomputed_soundtracks = [] # Reuse existing labels: hide all and add to pool for child: Label in search_list.get_children(): child.visible = false label_pool.append(child) - - soundtracks.append_array(array) - # Precompute cleaned versions for all soundtracks + set_soundtracks(array) for soundtrack: String in soundtracks: - var cleaned = clean_term(soundtrack) - precomputed_soundtracks.append({ - "original": soundtrack, - "lowercase": soundtrack.to_lower(), - "cleaned": cleaned - }) add_soundtrack_reuse(soundtrack) else: - print("Unexpected data") - Settings.make_request2("/music/all/order", handle_soundtracks, true) + GameLog.warning("Unexpected data") + MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks) + + +## Replaces the searchable soundtracks and precomputes their search forms. +func set_soundtracks(list: Array) -> void: + soundtracks = [] + precomputed_soundtracks = [] + soundtracks.append_array(list) + for soundtrack: String in soundtracks: + precomputed_soundtracks.append({ + "original": soundtrack, + "lowercase": soundtrack.to_lower(), + "cleaned": clean_term(soundtrack) + }) -func _input(event: InputEvent) -> void: - if self.visible == true: - if event is InputEventMouseButton && event.is_pressed(): - var evLocal: InputEvent = make_input_local(event) - if !Rect2(Vector2(0, 0), search_panel.size).has_point(evLocal.position): - self.visible = false func clear() -> void: search_bar.text = "" diff --git a/SearchWindow.gd.uid b/popups/search_popup.gd.uid similarity index 100% rename from SearchWindow.gd.uid rename to popups/search_popup.gd.uid diff --git a/SettingsWindow.gd b/popups/settings_popup.gd similarity index 61% rename from SettingsWindow.gd rename to popups/settings_popup.gd index 6cf69f7..bb65ecc 100644 --- a/SettingsWindow.gd +++ b/popups/settings_popup.gd @@ -1,81 +1,40 @@ -extends Control - -@onready -var settings_panel: Panel = %SettingsPanel - -@onready -var stop_after_current_button: CheckButton = %StopAfterCurrentCheckButton - -@onready -var hide_next_track_button: CheckButton = %HideNextTrackCheckButton - -@onready -var add_to_database_button: CheckButton = %AddToDatabaseCheckButton - -@onready -var low_played_button: CheckButton = %LowPlayedCheckButton - -@onready -var hide_beginning_button: CheckButton = %HideBeginningCheckButton - -@onready -var hide_length_button: CheckButton = %HideLengthCheckButton - -@onready -var hide_ticks_button: CheckButton = %HideTicksCheckButton - -@onready -var score_label: Label = %ScoreLabel - -@onready -var lower_winning_score_button: Button = %LowerButton - -@onready -var increase_winning_score_button: Button = %IncreaseButton - -@onready -var cache_label: Label = %CacheLabel - -@onready -var lower_cache_button: Button = %LowerCacheButton - -@onready -var increase_cache_button: Button = %IncreaseCacheButton - -@onready -var inspiration_speed_label: Label = %InspirationListSpeedLabel - -@onready -var lower_inspiration_speed_button: Button = %LowerInspirationSpeedButton - -@onready -var increase_inspiration_speed_button: Button = %IncreaseInspirationSpeedButton - -@onready -var hide_for_seconds_label: Label = %HideForSecondsLabel - -@onready -var lower_hide_for_seconds_button: Button = %LowerHideForSecondsButton - -@onready -var increase_hide_for_seconds_button: Button = %IncreaseHideForSecondsButton - -@onready -var select_server_button: OptionButton = %SelectServerButton - -@onready -var fullscreen_button: CheckButton = %FullscreenButton - -@onready -var local_button: CheckButton = %LocalButton +class_name SettingsPopup +extends BasePopup +## The settings: how songs are shown and played, the winning score, the song cache, +## which MusicServer to use and fullscreen. +## Emitted when another MusicServer is picked. signal server_changed -# Called when the node enters the scene tree for the first time. +@onready var settings_panel: Panel = %SettingsPanel +@onready var stop_after_current_button: CheckButton = %StopAfterCurrentCheckButton +@onready var hide_next_track_button: CheckButton = %HideNextTrackCheckButton +@onready var add_to_database_button: CheckButton = %AddToDatabaseCheckButton +@onready var low_played_button: CheckButton = %LowPlayedCheckButton +@onready var hide_beginning_button: CheckButton = %HideBeginningCheckButton +@onready var hide_length_button: CheckButton = %HideLengthCheckButton +@onready var hide_ticks_button: CheckButton = %HideTicksCheckButton +@onready var score_label: Label = %ScoreLabel +@onready var lower_winning_score_button: Button = %LowerButton +@onready var increase_winning_score_button: Button = %IncreaseButton +@onready var cache_label: Label = %CacheLabel +@onready var lower_cache_button: Button = %LowerCacheButton +@onready var increase_cache_button: Button = %IncreaseCacheButton +@onready var inspiration_speed_label: Label = %InspirationListSpeedLabel +@onready var lower_inspiration_speed_button: Button = %LowerInspirationSpeedButton +@onready var increase_inspiration_speed_button: Button = %IncreaseInspirationSpeedButton +@onready var hide_for_seconds_label: Label = %HideForSecondsLabel +@onready var lower_hide_for_seconds_button: Button = %LowerHideForSecondsButton +@onready var increase_hide_for_seconds_button: Button = %IncreaseHideForSecondsButton +@onready var select_server_button: OptionButton = %SelectServerButton +@onready var fullscreen_button: CheckButton = %FullscreenButton +@onready var local_button: CheckButton = %LocalButton + + func _ready() -> void: fullscreen_button.button_pressed = Settings.fullscreen select_server_button.selected = Settings.selected_server - + stop_after_current_button.pressed.connect(stop_after_current) hide_next_track_button.pressed.connect(hide_next_track) add_to_database_button.pressed.connect(add_to_database) @@ -93,7 +52,7 @@ func _ready() -> void: increase_hide_for_seconds_button.pressed.connect(increase_hide_for_seconds) fullscreen_button.pressed.connect(fullscreen) local_button.pressed.connect(local_play) - + stop_after_current_button.button_pressed = Settings.stop_after_current hide_next_track_button.button_pressed = Settings.hide_next_track add_to_database_button.button_pressed = Settings.add_to_stats @@ -109,111 +68,141 @@ func _ready() -> void: update_hide_for_seconds_enabled() select_server_button.select(Settings.selected_server) select_server_button.item_selected.connect(select_server) + visibility_changed.connect(_on_visibility_changed) + # Alt+Enter switches fullscreen from the main window, which resizes the window + get_window().size_changed.connect(_update_fullscreen_button) + + +# Settings can change outside this popup, e.g. winning turns statistics off +func _on_visibility_changed() -> void: + if visible: + add_to_database_button.button_pressed = Settings.add_to_stats + _update_fullscreen_button() + + +func _update_fullscreen_button() -> void: + fullscreen_button.set_pressed_no_signal(Settings.fullscreen) -func _input(event: InputEvent) -> void: - if self.visible == true: - if event is InputEventMouseButton && event.is_pressed(): - var evLocal: InputEvent = make_input_local(event) - if !Rect2(Vector2(0, 0), settings_panel.size).has_point(evLocal.position): - self.visible = false func fullscreen() -> void: Settings.fullscreen = !Settings.fullscreen - if Settings.fullscreen == true: + if Settings.fullscreen: DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) - if Settings.fullscreen == false: + if !Settings.fullscreen: DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + func local_play() -> void: Settings.play_local = !Settings.play_local - + + func stop_after_current() -> void: Settings.stop_after_current = !Settings.stop_after_current + func hide_next_track() -> void: Settings.hide_next_track = !Settings.hide_next_track - + + func add_to_database() -> void: Settings.add_to_stats = !Settings.add_to_stats - + + func low_played() -> void: Settings.use_low_played_mode = !Settings.use_low_played_mode + func hide_beginning() -> void: Settings.hide_beginning = !Settings.hide_beginning update_hide_for_seconds_enabled() + func update_hide_for_seconds_enabled() -> void: var enabled: bool = Settings.hide_beginning %HideForSecondsHBoxContainer.visible = enabled + func hide_length() -> void: Settings.hide_length = !Settings.hide_length + func hide_ticks() -> void: Settings.hide_ticks = !Settings.hide_ticks + func lower_winning_score() -> void: - Settings.winning_score -= 1 + Settings.winning_score = max(Settings.winning_score - 1, 1) score_label.text = str(Settings.winning_score) + func increase_winning_score() -> void: Settings.winning_score += 1 score_label.text = str(Settings.winning_score) + func set_winning_score(new_score: int) -> void: Settings.winning_score = new_score score_label.text = str(Settings.winning_score) + func lower_cache() -> void: Playlist.number_of_tracks_to_preload -= 1 if Playlist.number_of_tracks_to_preload < 1: Playlist.number_of_tracks_to_preload = 1 cache_label.text = str(Playlist.number_of_tracks_to_preload) + func increase_cache() -> void: Playlist.number_of_tracks_to_preload += 1 cache_label.text = str(Playlist.number_of_tracks_to_preload) + func lower_inspiration_speed() -> void: Settings.inspiration_list_speed -= 1 if Settings.inspiration_list_speed < 1: Settings.inspiration_list_speed = 1 inspiration_speed_label.text = str(Settings.inspiration_list_speed) + func increase_inspiration_speed() -> void: Settings.inspiration_list_speed += 1 inspiration_speed_label.text = str(Settings.inspiration_list_speed ) + func lower_hide_for_seconds() -> void: Settings.hide_for_seconds -= 1.0 if Settings.hide_for_seconds < 0: Settings.hide_for_seconds = 0 hide_for_seconds_label.text = str(Settings.hide_for_seconds) + func increase_hide_for_seconds() -> void: Settings.hide_for_seconds += 1.0 if Settings.hide_for_seconds > 10.0: Settings.hide_for_seconds = 10.0 hide_for_seconds_label.text = str(Settings.hide_for_seconds) + func select_server(new_server: int) -> void: - print("select_server") + GameLog.debug("select_server") Settings.default_path = select_server_button.get_item_text(new_server) Settings.selected_server = new_server - print("Settings.default_path: " + Settings.default_path) - print("Settings.selected_server: " + str(Settings.selected_server)) - print("new_server: " + str(new_server)) - print("select_server_button.get_item_text(new_server): " + select_server_button.get_item_text(new_server)) + GameLog.debug("Settings.default_path: " + Settings.default_path) + GameLog.debug("Settings.selected_server: " + str(Settings.selected_server)) + GameLog.debug("new_server: " + str(new_server)) + GameLog.debug("select_server_button.get_item_text(new_server): ", + select_server_button.get_item_text(new_server)) server_changed.emit() + func set_selected_server(new_server: int) -> void: - print("set_selected_server") + GameLog.debug("set_selected_server") select_server_button.select(new_server) Settings.default_path = select_server_button.get_item_text(new_server) Settings.selected_server = new_server - print("Settings.default_path: " + Settings.default_path) - print("Settings.selected_server: " + str(Settings.selected_server)) - print("new_server: " + str(new_server)) - print("select_server_button.get_item_text(new_server): " + select_server_button.get_item_text(new_server)) + GameLog.debug("Settings.default_path: " + Settings.default_path) + GameLog.debug("Settings.selected_server: " + str(Settings.selected_server)) + GameLog.debug("new_server: " + str(new_server)) + GameLog.debug("select_server_button.get_item_text(new_server): ", + select_server_button.get_item_text(new_server)) server_changed.emit() diff --git a/SettingsWindow.gd.uid b/popups/settings_popup.gd.uid similarity index 100% rename from SettingsWindow.gd.uid rename to popups/settings_popup.gd.uid diff --git a/shortcuts_window.gd b/popups/shortcuts_popup.gd similarity index 56% rename from shortcuts_window.gd rename to popups/shortcuts_popup.gd index 62a2282..e1b6d6e 100644 --- a/shortcuts_window.gd +++ b/popups/shortcuts_popup.gd @@ -1,10 +1,6 @@ -extends Control - -@onready -var shortcuts_vbox: VBoxContainer = %shortcutsVBox - -@onready -var shortcuts_panel: Panel = %ShortcutsPanel +class_name ShortcutsPopup +extends BasePopup +## Lists the keyboard shortcuts. Opened with Alt+K or the Shortcuts button. var shortcuts_dic: Dictionary = { "Alt + S": "Search", @@ -25,27 +21,16 @@ var shortcuts_dic: Dictionary = { "Alt + Ctrl + 1 - 9": "Take point from player", } -#Color(0.18, 0.18, 0.18) -#Color(0.302, 0.302, 0.302) -#Color(0.259, 0.259, 0.259) - -func _input(event: InputEvent) -> void: - if self.visible == true: - if shortcuts_panel == null: - return - if event is InputEventMouseButton && event.is_pressed(): - var evLocal: InputEvent = make_input_local(event) - if !Rect2(Vector2(0, 0), shortcuts_panel.size).has_point(evLocal.position): - self.visible = false +@onready var shortcuts_vbox: VBoxContainer = %shortcutsVBox +@onready var shortcuts_panel: Panel = %ShortcutsPanel func _ready() -> void: shortcuts_dic.sort() Settings.delete_children(shortcuts_vbox) - + var title: Label = Label.new() - title.set_texture_filter(TextureFilter.TEXTURE_FILTER_NEAREST) - title.add_theme_font_size_override("font_size", 20) + title.theme_type_variation = &"TextLarge" title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title.text = "Shortcuts" shortcuts_vbox.add_child(title) @@ -55,30 +40,24 @@ func _ready() -> void: var panel: PanelContainer = PanelContainer.new() var hbox: HBoxContainer = HBoxContainer.new() hbox.alignment = BoxContainer.ALIGNMENT_CENTER - + var label: Label = Label.new() - label.set_texture_filter(TextureFilter.TEXTURE_FILTER_NEAREST) - label.add_theme_font_size_override("font_size", 20) + label.theme_type_variation = &"TextLarge" label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.text = shortcut label.autowrap_mode = TextServer.AUTOWRAP_WORD label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - + var label2: Label = Label.new() - label2.set_texture_filter(TextureFilter.TEXTURE_FILTER_NEAREST) - label2.add_theme_font_size_override("font_size", 20) + label2.theme_type_variation = &"TextLarge" label2.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label2.text = shortcuts_dic[shortcut] label2.autowrap_mode = TextServer.AUTOWRAP_WORD label2.size_flags_horizontal = Control.SIZE_EXPAND_FILL - + if count % 2 == 0: - var new_stylebox_normal: Resource = panel.get_theme_stylebox("panel").duplicate() - new_stylebox_normal.bg_color = Color(0.18, 0.18, 0.18) - panel.add_theme_stylebox_override("panel", new_stylebox_normal) - #label.add_theme_stylebox_override("normal", new_stylebox_normal) - #label2.add_theme_stylebox_override("normal", new_stylebox_normal) - + panel.theme_type_variation = &"ShortcutRowDark" + hbox.add_child(label) hbox.add_child(label2) panel.add_child(hbox) diff --git a/shortcuts_window.gd.uid b/popups/shortcuts_popup.gd.uid similarity index 100% rename from shortcuts_window.gd.uid rename to popups/shortcuts_popup.gd.uid diff --git a/sync_window.gd b/popups/sync_popup.gd similarity index 50% rename from sync_window.gd rename to popups/sync_popup.gd index 382d3f4..1e4739f 100644 --- a/sync_window.gd +++ b/popups/sync_popup.gd @@ -1,88 +1,49 @@ -extends Control +class_name SyncPopup +extends BasePopup +## Asks the server to sync its soundtracks with the music folder, shows the progress, +## and then lists what was added, changed or removed. -@onready -var sync_panel: Panel = %SyncPanel - -@onready -var sync_title_label: Label = %SyncTitleLabel - -@onready -var no_changes_label: Label = %NoChangesLabel - -@onready -var progress_hbox: HBoxContainer = %ProgressHBoxContainer - -@onready -var sync_hbox: HBoxContainer = %SyncHBoxContainer - -@onready -var sync_progress_bar: ProgressBar = %SyncProgressBar - -@onready -var soundtracks_added_vbox: VBoxContainer = %SoundtracksAddedVBox - -@onready -var soundtracks_added_separator: VSeparator = %SoundtracksAddedSeparator - -@onready -var soundtracks_readded_vbox: VBoxContainer = %SoundtracksReAddedVBox - -@onready -var soundtracks_readded_separator: VSeparator = %SoundtracksReAddedSeparator - -@onready -var soundtracks_changed_title_vbox: VBoxContainer = %SoundtracksChangedTitleVBox - -@onready -var soundtracks_changed_title_separator: VSeparator = %SoundtracksChangedTitleSeparator - -@onready -var soundtracks_changed_content_vbox: VBoxContainer = %SoundtracksChangedContentVBox - -@onready -var soundtracks_changed_content_separator: VSeparator = %SoundtracksChangedContentSeparator - -@onready -var soundtracks_removed_vbox: VBoxContainer = %SoundtracksRemovedVBox - -@onready -var soundtracks_removed_separator: VSeparator = %SoundtracksRemovedSeparator - -@onready -var catched_errors_vbox: VBoxContainer = %CatchedErrorsVBox - -@onready -var sync_time: Label = %SyncTime - -@onready -var sync_timer: Timer = %SyncTimer - -@onready -var restart_sync_button: Button = %RestartSyncButton - -@onready -var first_separator: VSeparator = %VSeparator6 - -var changes: bool = false - -var has_synced: bool = false +## Emitted when the server has started syncing. signal sync_started +## Emitted when the sync is done and its result is shown. signal sync_finished +var changes: bool = false +var has_synced: bool = false + +@onready var sync_panel: Panel = %SyncPanel +@onready var sync_title_label: Label = %SyncTitleLabel +@onready var no_changes_label: Label = %NoChangesLabel +@onready var progress_hbox: HBoxContainer = %ProgressHBoxContainer +@onready var sync_hbox: HBoxContainer = %SyncHBoxContainer +@onready var sync_progress_bar: ProgressBar = %SyncProgressBar +@onready var soundtracks_added_vbox: VBoxContainer = %SoundtracksAddedVBox +@onready var soundtracks_added_separator: VSeparator = %SoundtracksAddedSeparator +@onready var soundtracks_readded_vbox: VBoxContainer = %SoundtracksReAddedVBox +@onready var soundtracks_readded_separator: VSeparator = %SoundtracksReAddedSeparator +@onready var soundtracks_changed_title_vbox: VBoxContainer = %SoundtracksChangedTitleVBox +@onready var soundtracks_changed_title_separator: VSeparator = %SoundtracksChangedTitleSeparator +@onready var soundtracks_changed_content_vbox: VBoxContainer = %SoundtracksChangedContentVBox +@onready var soundtracks_changed_content_separator: VSeparator = %SoundtracksChangedContentSeparator +@onready var soundtracks_removed_vbox: VBoxContainer = %SoundtracksRemovedVBox +@onready var soundtracks_removed_separator: VSeparator = %SoundtracksRemovedSeparator +@onready var caught_errors_vbox: VBoxContainer = %CaughtErrorsVBox +@onready var sync_time: Label = %SyncTime +@onready var sync_timer: Timer = %SyncTimer +@onready var restart_sync_button: Button = %RestartSyncButton + + func _ready() -> void: sync_timer.timeout.connect(_on_sync_timeout) restart_sync_button.pressed.connect(start_sync) restart_sync_button.visible = false -func _input(event: InputEvent) -> void: - if self.visible == true: - if event is InputEventMouseButton && event.is_pressed(): - var evLocal: InputEvent = make_input_local(event) - if !Rect2(Vector2(0, 0), sync_panel.size).has_point(evLocal.position): - self.visible = false -func show_section_if_has_data(data: Variant, vbox: VBoxContainer, separator: VSeparator, header_text: String) -> bool: +## Lists [param data] under [param header_text] in [param vbox] and returns true, or hides +## the column and its separator when there is nothing to list. +func show_section_if_has_data(data: Variant, vbox: VBoxContainer, separator: VSeparator, + header_text: String) -> bool: if data != null && data.size() > 0: vbox.visible = true make_list(data, vbox, header_text) @@ -92,9 +53,10 @@ func show_section_if_has_data(data: Variant, vbox: VBoxContainer, separator: VSe separator.visible = false return false + func start_sync() -> void: - print("start_sync") - self.visible = true + GameLog.debug("start_sync") + visible = true if has_synced: # Already synced, just show the results show_sync_result() @@ -105,57 +67,63 @@ func start_sync() -> void: sync_started.emit() show_sync_progress() if !Settings.currently_syncing: - Settings.make_request2("/sync", sync_request_sent, false) + MusicServerClient.start_sync(sync_request_sent) + func _on_sync_timeout() -> void: - print("_on_sync_timeout!") + GameLog.debug("_on_sync_timeout!") var get_progress: Callable = func get_progress(data_received: Dictionary) -> void: - print("get_progress") - print("data_received: ", data_received) + GameLog.debug("get_progress") + GameLog.debug("data_received: ", data_received) if data_received != null: if data_received.has("progress"): sync_progress_bar.value = int(data_received.progress) sync_time.text = data_received.time_spent if data_received.has("time_spent") else "" else: on_sync_finished(data_received) - Settings.make_request2("/sync/progress", get_progress, true) + MusicServerClient.fetch_sync_progress(get_progress) + func on_sync_finished(data_received: Dictionary) -> void: has_synced = true reset_sync_result() - - if show_section_if_has_data(data_received.soundtracks_added, soundtracks_added_vbox, soundtracks_added_separator, "Added"): + + if show_section_if_has_data(data_received.soundtracks_added, soundtracks_added_vbox, + soundtracks_added_separator, "Added"): changes = true - - if show_section_if_has_data(data_received.soundtracks_re_added, soundtracks_readded_vbox, soundtracks_readded_separator, "ReAdded"): + + if show_section_if_has_data(data_received.soundtracks_re_added, soundtracks_readded_vbox, + soundtracks_readded_separator, "Re-added"): changes = true - - if data_received.soundtracks_changed_title != null && data_received.soundtracks_changed_title.size() > 0: + + var changed_titles: Variant = data_received.soundtracks_changed_title + if changed_titles != null && changed_titles.size() > 0: changes = true Settings.delete_children(soundtracks_changed_title_vbox) make_header_label(soundtracks_changed_title_vbox, "Changed Title") var row: int = 0 - for old_soundtrack_title: String in data_received.soundtracks_changed_title: - var label: Label = make_soundtrack_label(old_soundtrack_title + "\nchanged title to\n" + data_received.soundtracks_changed_title[old_soundtrack_title]) - if row % 2 != 0: - label.add_theme_color_override("font_color", Color(1, 0.5, 0)) - soundtracks_changed_title_vbox.add_child(label) + for old_soundtrack_title: String in changed_titles: + var new_soundtrack_title: String = changed_titles[old_soundtrack_title] + soundtracks_changed_title_vbox.add_child(make_soundtrack_row( + old_soundtrack_title + "\nchanged title to\n" + new_soundtrack_title, row)) row += 1 soundtracks_changed_title_vbox.visible = true - - if show_section_if_has_data(data_received.soundtracks_changed_content, soundtracks_changed_content_vbox, soundtracks_changed_content_separator, "Changed Content"): + + if show_section_if_has_data(data_received.soundtracks_changed_content, + soundtracks_changed_content_vbox, soundtracks_changed_content_separator, + "Changed Content"): changes = true - - if show_section_if_has_data(data_received.soundtracks_removed, soundtracks_removed_vbox, soundtracks_removed_separator, "Removed"): + + if show_section_if_has_data(data_received.soundtracks_removed, soundtracks_removed_vbox, + soundtracks_removed_separator, "Removed"): changes = true - + + # The server's name for the errors it caught if data_received.catched_errors != null && data_received.catched_errors.size() > 0: - catched_errors_vbox.visible = true - make_list(data_received.catched_errors, catched_errors_vbox, "CatchedErrors") - - # Hide separators that don't have a visible section after them - # The structure is: RestartSyncButton -> VSeparator6 -> VBox -> Separator -> VBox -> Separator -> ... - # Hide VSeparator6 if there are no visible sections + caught_errors_vbox.visible = true + make_list(data_received.catched_errors, caught_errors_vbox, "Errors") + + # Separators go only between visible columns, not after the last one var visible_sections: Array = [] if soundtracks_added_vbox.visible: visible_sections.append("added") @@ -167,13 +135,11 @@ func on_sync_finished(data_received: Dictionary) -> void: visible_sections.append("changed_content") if soundtracks_removed_vbox.visible: visible_sections.append("removed") - - first_separator.visible = visible_sections.size() > 1 - + # Show section separators only if there's a visible section after them - for i in range(visible_sections.size()): - var section = visible_sections[i] - var separator_map = { + for i: int in range(visible_sections.size()): + var section: String = visible_sections[i] + var separator_map: Dictionary = { "added": soundtracks_added_separator, "readded": soundtracks_readded_separator, "changed_title": soundtracks_changed_title_separator, @@ -185,37 +151,37 @@ func on_sync_finished(data_received: Dictionary) -> void: separator_map[section].visible = true else: separator_map[section].visible = false - + if !changes: no_changes_label.visible = true - sync_time.text = "Total time: " + data_received.total_time if data_received.has("total_time") else "" + sync_time.text = "Total time: " + data_received.total_time \ + if data_received.has("total_time") else "" Settings.currently_syncing = false sync_timer.stop() - print("sync finished") + GameLog.debug("sync finished") show_sync_result() restart_sync_button.visible = true sync_finished.emit() -func list_visible(vbox: VBoxContainer, seperator: VSeparator) -> void: + +func list_visible(vbox: VBoxContainer, separator: VSeparator) -> void: vbox.visible = true - seperator.visible = true + separator.visible = true + func make_header_label(vbox: VBoxContainer, text: String) -> void: var label: Label = Label.new() - label.set_texture_filter(TextureFilter.TEXTURE_FILTER_NEAREST) - label.add_theme_font_size_override("font_size", 20) + label.theme_type_variation = &"HighlightLarge" label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - label.add_theme_color_override("font_color", Color(1, 0.5, 0)) label.text = text vbox.add_child(label) -func make_soundtrack_label(text: String) -> Label: - var label: Label = Label.new() - label.set_texture_filter(TextureFilter.TEXTURE_FILTER_NEAREST) - label.add_theme_font_size_override("font_size", 18) - label.text = text - label.autowrap_mode = TextServer.AUTOWRAP_OFF - return label + +# Items alternate between white and orange text as well as background shade +func make_soundtrack_row(text: String, row: int) -> ListRow: + var label_style: StringName = &"HighlightMedium" if row % 2 != 0 else &"TextMedium" + return ListRow.create(text, row, label_style, HORIZONTAL_ALIGNMENT_CENTER) + func reset_sync_result() -> void: sync_progress_bar.value = 0 @@ -232,31 +198,27 @@ func reset_sync_result() -> void: soundtracks_changed_content_separator.visible = false soundtracks_removed_vbox.visible = false soundtracks_removed_separator.visible = false - catched_errors_vbox.visible = false - first_separator.visible = false + caught_errors_vbox.visible = false + func make_list(list: Array, vbox: VBoxContainer, text: String) -> void: Settings.delete_children(vbox) make_header_label(vbox, text) - var row: int = 0 - for soundtrack: String in list: - var label: Label = make_soundtrack_label(soundtrack) - if row % 2 != 0: - label.add_theme_color_override("font_color", Color(1, 0.5, 0)) - vbox.add_child(label) - row += 1 + for row: int in list.size(): + vbox.add_child(make_soundtrack_row(list[row], row)) + func show_sync_result() -> void: sync_title_label.text = "Sync Result" progress_hbox.visible = false sync_hbox.visible = true restart_sync_button.visible = true - + + func show_sync_progress() -> void: sync_title_label.text = "Sync in Progress" progress_hbox.visible = true no_changes_label.visible = false sync_hbox.visible = false - catched_errors_vbox.visible = false + caught_errors_vbox.visible = false restart_sync_button.visible = false - first_separator.visible = false diff --git a/sync_window.gd.uid b/popups/sync_popup.gd.uid similarity index 100% rename from sync_window.gd.uid rename to popups/sync_popup.gd.uid diff --git a/winner_window.gd b/popups/winner_popup.gd similarity index 62% rename from winner_window.gd rename to popups/winner_popup.gd index 029c3e8..4330f3f 100644 --- a/winner_window.gd +++ b/popups/winner_popup.gd @@ -1,29 +1,28 @@ -extends Control +class_name WinnerPopup +extends BasePopup +## Shows who won, with their character, and where the other players placed. -@onready -var winner_panel: Panel = %WinnerPanel - -@onready -var winner_picture: TextureRect = %WinnerPicture - -@onready -var winner_label: Label = %WinnerLabel - -@onready -var players_list: VBoxContainer = %PlayersList +@onready var winner_panel: Panel = %WinnerPanel +@onready var winner_picture: TextureRect = %WinnerPicture +@onready var winner_label: Label = %WinnerLabel +@onready var players_list: VBoxContainer = %PlayersList func _ready() -> void: visible = false -func show_winner(winning_player_id: int) -> void: +func show_winner(winner: PlayerObject) -> void: visible = true # Set winner info - var winner: PlayerObject = Settings.player_array[winning_player_id] winner_label.text = winner.player_name + " won with " + str(winner.player_score) + \ " points!!" winner_picture.texture = winner.character + # The "no character" placeholder is pixel art, which blurs when scaled up smoothly + if winner.character.resource_path == PlayerObject.NO_CHARACTER_PATH: + winner_picture.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + else: + winner_picture.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR winner_picture.custom_minimum_size = Vector2(480, 200) winner_picture.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL winner_picture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED @@ -41,7 +40,7 @@ func show_winner(winning_player_id: int) -> void: var placing: int = 2 for player: PlayerObject in sorted_players: - if player.id == winning_player_id: + if player == winner: continue # Skip the winner, already shown above var label: Label = Label.new() label.text = str(placing) + ". " + player.player_name + " - " + \ @@ -49,15 +48,3 @@ func show_winner(winning_player_id: int) -> void: label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER players_list.add_child(label) placing += 1 - - -func hide_window() -> void: - visible = false - - -func _input(event: InputEvent) -> void: - if visible: - if event is InputEventMouseButton and event.is_pressed(): - var ev_local: InputEvent = make_input_local(event) - if !Rect2(Vector2(0, 0), winner_panel.size).has_point(ev_local.position): - visible = false diff --git a/winner_window.gd.uid b/popups/winner_popup.gd.uid similarity index 100% rename from winner_window.gd.uid rename to popups/winner_popup.gd.uid diff --git a/progress_response.gd b/progress_response.gd deleted file mode 100644 index f1085e7..0000000 --- a/progress_response.gd +++ /dev/null @@ -1,5 +0,0 @@ -class_name ProgressResponse -extends Node - -var progress: String -var total_time: String diff --git a/progress_response.gd.uid b/progress_response.gd.uid deleted file mode 100644 index 7ea6704..0000000 --- a/progress_response.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://e74iuu78stc3 diff --git a/project.godot b/project.godot index 0ecb1d1..56fa28a 100644 --- a/project.godot +++ b/project.godot @@ -15,14 +15,17 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true [application] config/name="MusicPlayer" -run/main_scene="res://MainWindow.tscn" -config/features=PackedStringArray("4.7", "Forward Plus") +config/version="2.0.0" +run/main_scene="res://main/MainWindow.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://icon.svg" [autoload] -Settings="*res://Settings.gd" -Playlist="*res://playlist.gd" +GameLog="*res://common/game_log.gd" +Settings="*res://data/settings.gd" +Playlist="*res://data/playlist.gd" +MusicServerClient="*res://server/music_server_client.gd" [debug] @@ -43,7 +46,11 @@ project/assembly_name="MusicPlayer" [editor_plugins] -enabled=PackedStringArray("res://addons/kenyoni/qr_code/plugin.cfg") +enabled=PackedStringArray("res://addons/kenyoni/qr_code/plugin.cfg", "res://addons/gdUnit4/plugin.cfg") + +[gui] + +theme/custom="res://themes/main_theme.tres" [input] @@ -59,6 +66,9 @@ ui_select={ [rendering] +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" + textures/vram_compression/import_s3tc_bptc=true textures/vram_compression/import_etc2_astc=true textures/default_filters/use_nearest_mipmap_filter=true diff --git a/quiz/InspirationList.tscn b/quiz/InspirationList.tscn new file mode 100644 index 0000000..717d103 --- /dev/null +++ b/quiz/InspirationList.tscn @@ -0,0 +1,16 @@ +[gd_scene format=3] + +[ext_resource type="Script" uid="uid://elgn7gkxgagq" path="res://quiz/inspiration_list.gd" id="2_gxtxm"] + +[node name="InspirationList" type="ScrollContainer" unique_id=498283761] +layout_mode = 3 +horizontal_scroll_mode = 0 +vertical_scroll_mode = 3 +offset_right = 641.0 +offset_bottom = 384.0 +script = ExtResource("2_gxtxm") + +[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=1215162949] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 diff --git a/quiz/MusicListPanel.tscn b/quiz/MusicListPanel.tscn new file mode 100644 index 0000000..09edac4 --- /dev/null +++ b/quiz/MusicListPanel.tscn @@ -0,0 +1,23 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://quiz/music_list_panel.gd" id="part_script"] +[ext_resource type="Script" uid="uid://gf1bk6xbe3j" path="res://quiz/scroll_to_end_container.gd" id="7_dj026"] + +[node name="MusicListPanel" type="PanelContainer" unique_id=2011292375] +layout_mode = 3 +offset_right = 1088.0 +offset_bottom = 512.0 +theme_type_variation = &"MusicList" +script = ExtResource("part_script") + +[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=707750459] +layout_mode = 2 +follow_focus = true +script = ExtResource("7_dj026") + +[node name="MusicList" type="VBoxContainer" parent="ScrollContainer" unique_id=373398568] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/separation = 10 diff --git a/quiz/NowPlaying.tscn b/quiz/NowPlaying.tscn new file mode 100644 index 0000000..38d1c30 --- /dev/null +++ b/quiz/NowPlaying.tscn @@ -0,0 +1,23 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://quiz/now_playing.gd" id="part_script"] + +[node name="NowPlaying" type="VBoxContainer" unique_id=42468498] +layout_mode = 3 +offset_right = 1088.0 +offset_bottom = 320.0 +script = ExtResource("part_script") + +[node name="SoundtrackLabel" type="Label" parent="." unique_id=173147793] +custom_minimum_size = Vector2(1, 1) +layout_mode = 2 +theme_type_variation = &"TitleLarge" +horizontal_alignment = 1 +autowrap_mode = 2 + +[node name="SongLabel" type="Label" parent="." unique_id=1687490027] +custom_minimum_size = Vector2(1, 1) +layout_mode = 2 +theme_type_variation = &"TitleMedium" +horizontal_alignment = 1 +autowrap_mode = 2 diff --git a/Player.tscn b/quiz/PlayerRow.tscn similarity index 64% rename from Player.tscn rename to quiz/PlayerRow.tscn index 97cc18a..463e2f5 100644 --- a/Player.tscn +++ b/quiz/PlayerRow.tscn @@ -1,25 +1,10 @@ -[gd_scene load_steps=9 format=3 uid="uid://cslogy2csnd7a"] +[gd_scene format=3 uid="uid://cslogy2csnd7a"] -[ext_resource type="Script" uid="uid://dl6no2wr2c86y" path="res://Player.gd" id="1_if4kc"] +[ext_resource type="Script" uid="uid://dl6no2wr2c86y" path="res://quiz/player_row.gd" id="1_if4kc"] [ext_resource type="Texture2D" uid="uid://r4as0nmtoa7p" path="res://noCharacter.png" id="2_hpj3s"] [ext_resource type="Texture2D" uid="uid://b2kj6m8qpsgb1" path="res://icons/person_remove_light.svg" id="3_j3uxe"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_821k2"] -bg_color = Color(0.180392, 0.180392, 0.180392, 1) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w10e8"] -bg_color = Color(0.180392, 0.180392, 0.180392, 1) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_78a8g"] -bg_color = Color(0.180392, 0.180392, 0.180392, 1) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i56hb"] -bg_color = Color(0.180392, 0.180392, 0.180392, 1) - -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_b2crt"] -bg_color = Color(0.180392, 0.180392, 0.180392, 1) - -[node name="Player" type="Control"] +[node name="PlayerRow" type="Control"] custom_minimum_size = Vector2(2.08165e-12, 40) layout_mode = 3 anchors_preset = 0 @@ -29,28 +14,36 @@ script = ExtResource("1_if4kc") [node name="HBoxContainer" type="HBoxContainer" parent="."] custom_minimum_size = Vector2(2.08165e-12, 40) -layout_mode = 0 -offset_right = 40.0 -offset_bottom = 40.0 +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 [node name="Panel4" type="Panel" parent="HBoxContainer"] custom_minimum_size = Vector2(5, 40) layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_821k2") +theme_type_variation = &"DarkSpacer" [node name="Name" type="Label" parent="HBoxContainer"] +unique_name_in_owner = true custom_minimum_size = Vector2(130, 40) layout_mode = 2 +size_flags_horizontal = 3 size_flags_vertical = 1 text = ": 0" vertical_alignment = 1 +clip_text = true +text_overrun_behavior = 3 [node name="Panel3" type="Panel" parent="HBoxContainer"] -custom_minimum_size = Vector2(80, 2.08165e-12) +custom_minimum_size = Vector2(16, 2.08165e-12) layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_w10e8") +theme_type_variation = &"DarkSpacer" [node name="Character" type="TextureButton" parent="HBoxContainer"] +unique_name_in_owner = true custom_minimum_size = Vector2(80, 40) layout_mode = 2 focus_mode = 0 @@ -62,9 +55,10 @@ stretch_mode = 5 [node name="Panel2" type="Panel" parent="HBoxContainer"] custom_minimum_size = Vector2(30, 40) layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_78a8g") +theme_type_variation = &"DarkSpacer" [node name="AddPoint" type="Button" parent="HBoxContainer"] +unique_name_in_owner = true custom_minimum_size = Vector2(40, 40) layout_mode = 2 focus_mode = 0 @@ -74,9 +68,10 @@ text = "+1" [node name="Panel5" type="Panel" parent="HBoxContainer"] custom_minimum_size = Vector2(3, 40) layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_i56hb") +theme_type_variation = &"DarkSpacer" [node name="RemovePoint" type="Button" parent="HBoxContainer"] +unique_name_in_owner = true custom_minimum_size = Vector2(40, 40) layout_mode = 2 focus_mode = 0 @@ -86,12 +81,12 @@ text = "-1" [node name="Panel" type="Panel" parent="HBoxContainer"] custom_minimum_size = Vector2(30, 40) layout_mode = 2 -theme_override_styles/panel = SubResource("StyleBoxFlat_b2crt") +theme_type_variation = &"DarkSpacer" [node name="RemovePlayer" type="TextureButton" parent="HBoxContainer"] +unique_name_in_owner = true custom_minimum_size = Vector2(40, 40) layout_mode = 2 -size_flags_horizontal = 3 focus_mode = 0 action_mode = 0 texture_normal = ExtResource("3_j3uxe") diff --git a/quiz/PlayersPanel.tscn b/quiz/PlayersPanel.tscn new file mode 100644 index 0000000..fd01079 --- /dev/null +++ b/quiz/PlayersPanel.tscn @@ -0,0 +1,88 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://quiz/players_panel.gd" id="part_script"] +[ext_resource type="Texture2D" uid="uid://bcfmpd7h512ef" path="res://icons/person_add_light.svg" id="5_31tjv"] +[ext_resource type="Script" uid="uid://cgakje8gb37tf" path="res://quiz/player_name_field.gd" id="7_qsdfy"] + +[sub_resource type="InputEventKey" id="InputEventKey_03bm3"] +device = -1 +alt_pressed = true +keycode = 65 +unicode = 63743 + +[sub_resource type="Shortcut" id="Shortcut_jafqj"] +events = [SubResource("InputEventKey_03bm3")] + +[node name="PlayersPanel" type="PanelContainer" unique_id=1708375945] +layout_mode = 3 +offset_right = 641.0 +offset_bottom = 401.0 +script = ExtResource("part_script") + +[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=270084211] +layout_mode = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer" unique_id=513047023] +custom_minimum_size = Vector2(2.08165e-12, 40) +layout_mode = 2 + +[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer" unique_id=1331357386] +custom_minimum_size = Vector2(2.08165e-12, 40) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 1 +text = "Players" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="AddPlayersButton" type="TextureButton" parent="VBoxContainer/HBoxContainer" unique_id=634410148] +unique_name_in_owner = true +custom_minimum_size = Vector2(2.08165e-12, 40) +layout_mode = 2 +size_flags_horizontal = 8 +focus_mode = 0 +action_mode = 0 +shortcut = SubResource("Shortcut_jafqj") +texture_normal = ExtResource("5_31tjv") + +[node name="AddPlayerContainer" type="HBoxContainer" parent="VBoxContainer" unique_id=1371948739] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(2.08165e-12, 40) +layout_mode = 2 + +[node name="Panel" type="Panel" parent="VBoxContainer/AddPlayerContainer" unique_id=1569854056] +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"DarkSpacer" + +[node name="PlayerNameField" type="TextEdit" parent="VBoxContainer/AddPlayerContainer" unique_id=332535327] +unique_name_in_owner = true +custom_minimum_size = Vector2(2.08165e-12, 40) +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Player name" +script = ExtResource("7_qsdfy") + +[node name="AddPlayerButton" type="Button" parent="VBoxContainer/AddPlayerContainer" unique_id=245001313] +unique_name_in_owner = true +custom_minimum_size = Vector2(2.08165e-12, 40) +layout_mode = 2 +text = "Add" + +[node name="Panel2" type="Panel" parent="VBoxContainer/AddPlayerContainer" unique_id=60215448] +custom_minimum_size = Vector2(2.08165e-12, 40) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"DarkSpacer" + +[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer" unique_id=1000000001] +layout_mode = 2 +size_flags_vertical = 15 +horizontal_scroll_mode = 0 + +[node name="PlayersListVBoxContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer" unique_id=1000000002] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 15 diff --git a/quiz/Quiz.tscn b/quiz/Quiz.tscn new file mode 100644 index 0000000..2e793c4 --- /dev/null +++ b/quiz/Quiz.tscn @@ -0,0 +1,135 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://quiz/quiz.gd" id="quiz_script"] +[ext_resource type="PackedScene" uid="uid://ds15cgsf8vpvc" path="res://player/MusicPlayer.tscn" id="5_emn36"] +[ext_resource type="PackedScene" uid="uid://cdy4kvemwaiom" path="res://popups/CharacterSelectPopup.tscn" id="6_dhrvg"] +[ext_resource type="PackedScene" uid="uid://dldpeo5y3l5hq" path="res://popups/SettingsPopup.tscn" id="11_k62u5"] +[ext_resource type="PackedScene" uid="uid://cxlsmxnrp0agh" path="res://popups/WinnerPopup.tscn" id="17_winner_window"] +[ext_resource type="PackedScene" path="res://quiz/MusicListPanel.tscn" id="scene_MusicListPanel"] +[ext_resource type="PackedScene" path="res://quiz/PlayersPanel.tscn" id="scene_PlayersPanel"] +[ext_resource type="PackedScene" path="res://quiz/InspirationList.tscn" id="scene_InspirationList"] +[ext_resource type="PackedScene" path="res://quiz/QuizToolbar.tscn" id="scene_QuizToolbar"] +[ext_resource type="PackedScene" path="res://quiz/SongControls.tscn" id="scene_SongControls"] +[ext_resource type="PackedScene" path="res://quiz/NowPlaying.tscn" id="scene_NowPlaying"] +[ext_resource type="Script" path="res://quiz/song_loader.gd" id="song_loader_script"] + +[node name="Quiz" type="Control" unique_id=1146705218] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("quiz_script") + +[node name="Open" type="Button" parent="." unique_id=165052329] +visible = false +layout_mode = 0 +offset_left = 1296.0 +offset_top = 66.0 +offset_right = 1346.0 +offset_bottom = 97.0 +text = "Open" + +[node name="FileDialog" type="FileDialog" parent="." unique_id=485771559] +title = "Open a Directory" +initial_position = 2 +size = Vector2i(616, 400) +ok_button_text = "Select Current Folder" +file_mode = 2 +access = 2 +show_hidden_files = true + +[node name="MusicListPanel" parent="." unique_id=2011292375 instance=ExtResource("scene_MusicListPanel")] +layout_mode = 0 +offset_left = 64.0 +offset_top = 384.0 +offset_right = 1152.0 +offset_bottom = 896.0 + +[node name="InspirationList" parent="." unique_id=498283761 instance=ExtResource("scene_InspirationList")] +layout_mode = 0 +offset_left = 1216.0 +offset_top = 128.0 +offset_right = 1857.0 +offset_bottom = 512.0 + +[node name="NowPlaying" parent="." unique_id=42468498 instance=ExtResource("scene_NowPlaying")] +layout_mode = 0 +offset_left = 64.0 +offset_top = 64.0 +offset_right = 1152.0 +offset_bottom = 384.0 + +[node name="MusicPlayer" parent="." unique_id=1990459832 instance=ExtResource("5_emn36")] +layout_mode = 0 +offset_left = 64.0 +offset_top = 992.0 +offset_right = 1856.0 +offset_bottom = 1063.0 + +[node name="PlayersPanel" parent="." unique_id=1708375945 instance=ExtResource("scene_PlayersPanel")] +layout_mode = 0 +offset_left = 1216.0 +offset_top = 576.0 +offset_right = 1857.0 +offset_bottom = 977.0 + +[node name="QuizToolbar" parent="." unique_id=1873301941 instance=ExtResource("scene_QuizToolbar")] +layout_mode = 0 +offset_left = 1360.0 +offset_top = 64.0 +offset_right = 1855.0 +offset_bottom = 95.0 + +[node name="SongControls" parent="." unique_id=1640212773 instance=ExtResource("scene_SongControls")] +layout_mode = 0 +offset_left = 65.0 +offset_top = 944.0 +offset_right = 368.0 +offset_bottom = 975.0 + +[node name="SettingsPopup" parent="." unique_id=1323246978 instance=ExtResource("11_k62u5")] +visible = false +layout_mode = 1 +offset_left = 720.0 +offset_top = 153.0 +offset_right = 720.0 +offset_bottom = 153.0 + +[node name="CharacterSelectPopup" parent="." unique_id=1679856322 instance=ExtResource("6_dhrvg")] +unique_name_in_owner = true +visible = false +layout_mode = 1 +offset_left = 405.0 +offset_top = 165.0 +offset_right = 1557.0 +offset_bottom = 805.0 + +[node name="WinnerPopup" parent="." unique_id=999999999 instance=ExtResource("17_winner_window")] +unique_name_in_owner = true +visible = false +layout_mode = 1 +offset_left = 720.0 +offset_top = 153.0 +offset_right = 720.0 +offset_bottom = 153.0 + +[node name="DebugLabel" type="Label" parent="." unique_id=1959221257] +visible = false +layout_mode = 0 +offset_left = 974.0 +offset_top = 904.0 +offset_right = 1014.0 +offset_bottom = 927.0 +scale = Vector2(3, 3) +text = "DEBUG" +horizontal_alignment = 1 +vertical_alignment = 1 +autowrap_mode = 2 + +[node name="SongLoader" type="Node" parent="." unique_id=1520867349] +script = ExtResource("song_loader_script") + +[connection signal="dir_selected" from="FileDialog" to="." method="_on_file_dialog_dir_selected"] diff --git a/quiz/QuizToolbar.tscn b/quiz/QuizToolbar.tscn new file mode 100644 index 0000000..d03b7e6 --- /dev/null +++ b/quiz/QuizToolbar.tscn @@ -0,0 +1,45 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://quiz/quiz_toolbar.gd" id="part_script"] + +[node name="QuizToolbar" type="Control" unique_id=1873301941] +layout_mode = 3 +offset_right = 495.0 +offset_bottom = 31.0 +mouse_filter = 2 +script = ExtResource("part_script") + +[node name="ResetPlaylistButton" type="Button" parent="." unique_id=1754155620] +layout_mode = 0 +offset_right = 111.0 +offset_bottom = 31.0 +focus_mode = 0 +action_mode = 0 +text = "Reset playlist" + +[node name="ResetPointsButton" type="Button" parent="." unique_id=942880776] +layout_mode = 0 +offset_left = 128.0 +offset_right = 239.0 +offset_bottom = 31.0 +focus_mode = 0 +action_mode = 0 +text = "Reset points" + +[node name="SettingsButton" type="Button" parent="." unique_id=1193400323] +layout_mode = 0 +offset_left = 256.0 +offset_right = 367.0 +offset_bottom = 31.0 +focus_mode = 0 +action_mode = 0 +text = "Settings" + +[node name="SoundTestButton" type="Button" parent="." unique_id=1332971879] +layout_mode = 0 +offset_left = 384.0 +offset_right = 495.0 +offset_bottom = 31.0 +focus_mode = 0 +action_mode = 0 +text = "Sound test" diff --git a/quiz/SongControls.tscn b/quiz/SongControls.tscn new file mode 100644 index 0000000..8df5f48 --- /dev/null +++ b/quiz/SongControls.tscn @@ -0,0 +1,48 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://quiz/song_controls.gd" id="part_script"] + +[sub_resource type="InputEventKey" id="InputEventKey_06rl4"] +device = -1 +alt_pressed = true +keycode = 86 +unicode = 8249 + +[sub_resource type="Shortcut" id="Shortcut_a7fvb"] +events = [SubResource("InputEventKey_06rl4")] + +[sub_resource type="InputEventKey" id="InputEventKey_7dnqw"] +device = -1 +alt_pressed = true +keycode = 67 +unicode = 231 + +[sub_resource type="Shortcut" id="Shortcut_d6fml"] +events = [SubResource("InputEventKey_7dnqw")] + +[node name="SongControls" type="Control" unique_id=1640212773] +layout_mode = 3 +offset_right = 303.0 +offset_bottom = 31.0 +mouse_filter = 2 +script = ExtResource("part_script") + +[node name="ShowAnswerButton" type="Button" parent="." unique_id=415388039] +layout_mode = 0 +offset_right = 111.0 +offset_bottom = 31.0 +focus_mode = 0 +action_mode = 0 +shortcut = SubResource("Shortcut_a7fvb") +text = "Show answer" + +[node name="NextButton" type="Button" parent="." unique_id=1744703488] +layout_mode = 0 +offset_left = 128.0 +offset_right = 303.0 +offset_bottom = 31.0 +focus_mode = 0 +disabled = true +action_mode = 0 +shortcut = SubResource("Shortcut_d6fml") +text = "Randomize new track" diff --git a/song_list_object.tscn b/quiz/SongRow.tscn similarity index 77% rename from song_list_object.tscn rename to quiz/SongRow.tscn index d804575..51e1355 100644 --- a/song_list_object.tscn +++ b/quiz/SongRow.tscn @@ -1,15 +1,13 @@ -[gd_scene load_steps=5 format=3 uid="uid://1jbtmhbqolqe"] +[gd_scene format=3 uid="uid://1jbtmhbqolqe"] [ext_resource type="Texture2D" uid="uid://c8r7ykphss4c2" path="res://characters/Link.png" id="1_ll28s"] -[ext_resource type="Script" uid="uid://cjo13wixip8mq" path="res://song_list_object.gd" id="1_lspki"] -[ext_resource type="Theme" uid="uid://8bllart6xn1f" path="res://default_theme.tres" id="1_x7fq8"] -[ext_resource type="StyleBox" uid="uid://bsv6yb0yir04l" path="res://style_box_flat.tres" id="3_5h84f"] +[ext_resource type="Script" uid="uid://cjo13wixip8mq" path="res://quiz/song_row.gd" id="1_lspki"] -[node name="SongListObject" type="PanelContainer"] +[node name="SongRow" type="PanelContainer"] custom_minimum_size = Vector2(1000, 0) offset_right = 1088.0 offset_bottom = 59.0 -theme = ExtResource("1_x7fq8") +theme_type_variation = &"SongRow" script = ExtResource("1_lspki") [node name="HBoxContainer" type="HBoxContainer" parent="."] @@ -17,14 +15,13 @@ layout_mode = 2 size_flags_vertical = 0 [node name="SongListLabel" type="Label" parent="HBoxContainer"] +unique_name_in_owner = true texture_filter = 2 custom_minimum_size = Vector2(865, 28) layout_mode = 2 size_flags_horizontal = 3 mouse_filter = 1 -theme = ExtResource("1_x7fq8") -theme_override_font_sizes/font_size = 20 -theme_override_styles/normal = ExtResource("3_5h84f") +theme_type_variation = &"SongRowLabel" text = "Final Fantasy Dissidia 012 Duodecim (Sista Fantasin Meningsskiljaktigheter 012 Tolv) - 'Crystal Cave -arrange-'' from FINAL FANTASY III" autowrap_mode = 2 @@ -34,6 +31,7 @@ size_flags_horizontal = 3 size_flags_vertical = 0 [node name="SongListPlayers" type="HBoxContainer" parent="HBoxContainer"] +unique_name_in_owner = true layout_mode = 2 size_flags_vertical = 0 diff --git a/quiz/inspiration_list.gd b/quiz/inspiration_list.gd new file mode 100644 index 0000000..1a691ca --- /dev/null +++ b/quiz/inspiration_list.gd @@ -0,0 +1,68 @@ +class_name InspirationList +extends ScrollContainer +## The list of every soundtrack, shown for inspiration. It scrolls slowly up and down +## by itself, at the speed set in the settings. + +## The soundtracks shown, in order. + +var soundtracks: Array = [] + +# Scroll direction: 1 = down, -1 = up +var scroll_direction: int = 1 + +@onready var _rows: VBoxContainer = $VBoxContainer + + +func _ready() -> void: + scroll_direction = 1 + + +func _process(delta: float) -> void: + var speed_pixels_per_second: float = float(Settings.inspiration_list_speed) * 60.0 + var scroll_amount: float = speed_pixels_per_second * delta * float(scroll_direction) + + var scroll_bar: VScrollBar = get_v_scroll_bar() + + # Don't scroll if list is empty or too short + if scroll_bar.max_value <= 0: + return + + var current_pos: float = scroll_bar.value + + if scroll_direction == 1: + # Scrolling down + if current_pos + scroll_bar.page < scroll_bar.max_value: + scroll_bar.value = minf(current_pos + scroll_amount, + scroll_bar.max_value - scroll_bar.page) + else: + # Reached bottom, reverse direction + scroll_direction = -1 + scroll_bar.value = scroll_bar.max_value - scroll_bar.page + else: + # Scrolling up + if current_pos > 0: + scroll_bar.value = maxf(current_pos + scroll_amount, 0) + else: + # Reached top, reverse direction + scroll_direction = 1 + scroll_bar.value = 0 + + +## Fetches every soundtrack from the server and shows them. +func load_from_server() -> void: + GameLog.debug("load_from_server") + var populate_list: Callable = func populate_list(array: Variant) -> void: + if typeof(array) == TYPE_ARRAY: + show_soundtracks(array) + else: + GameLog.warning("Unexpected data") + MusicServerClient.fetch_all_soundtracks(populate_list) + + +## Shows [param list] instead of the soundtracks shown now. +func show_soundtracks(list: Array) -> void: + soundtracks = [] + soundtracks.append_array(list) + Settings.delete_children(_rows) + for i: int in soundtracks.size(): + _rows.add_child(ListRow.create(soundtracks[i], i, &"TextLarge")) diff --git a/InsperationScrollContainer.gd.uid b/quiz/inspiration_list.gd.uid similarity index 100% rename from InsperationScrollContainer.gd.uid rename to quiz/inspiration_list.gd.uid diff --git a/quiz/music_list_panel.gd b/quiz/music_list_panel.gd new file mode 100644 index 0000000..ec8b90b --- /dev/null +++ b/quiz/music_list_panel.gd @@ -0,0 +1,26 @@ +class_name MusicListPanel +extends PanelContainer +## The list of songs in the playlist, one row per song. + +## Emitted when the row of song number [param song_no] is clicked. + +signal song_clicked(song_no: int) + +var _song_list_object: PackedScene = preload("res://quiz/SongRow.tscn") + +@onready var _rows: VBoxContainer = %MusicList + + +## Shows the songs in the playlist again, so it shows their latest state. +func refresh() -> void: + Settings.delete_children(_rows) + for s: SongObject in Playlist.get_playlist(): + var new_song_list_object: SongRow = _song_list_object.instantiate() + new_song_list_object.song_object = s + new_song_list_object.gui_input.connect(_on_row_input.bind(s.song_number)) + _rows.add_child(new_song_list_object) + + +func _on_row_input(event: InputEvent, song_no: int) -> void: + if event is InputEventMouseButton && event.pressed && event.button_index == MOUSE_BUTTON_LEFT: + song_clicked.emit(song_no) diff --git a/quiz/music_list_panel.gd.uid b/quiz/music_list_panel.gd.uid new file mode 100644 index 0000000..2b22754 --- /dev/null +++ b/quiz/music_list_panel.gd.uid @@ -0,0 +1 @@ +uid://b8e27uui01tt diff --git a/quiz/now_playing.gd b/quiz/now_playing.gd new file mode 100644 index 0000000..634ca87 --- /dev/null +++ b/quiz/now_playing.gd @@ -0,0 +1,10 @@ +class_name NowPlaying +extends VBoxContainer +## The soundtrack and song that are playing, in large text at the top of the quiz. + + +## Shows [param soundtrack_title] and [param song_title]. The playlist hides them as +## question marks until the answer is shown. +func show_song(soundtrack_title: String, song_title: String) -> void: + $SoundtrackLabel.text = soundtrack_title + $SongLabel.text = song_title diff --git a/quiz/now_playing.gd.uid b/quiz/now_playing.gd.uid new file mode 100644 index 0000000..c271808 --- /dev/null +++ b/quiz/now_playing.gd.uid @@ -0,0 +1 @@ +uid://beoi75d4u568h diff --git a/PlayerNameField.gd b/quiz/player_name_field.gd similarity index 52% rename from PlayerNameField.gd rename to quiz/player_name_field.gd index 2d06140..c5f6f95 100644 --- a/PlayerNameField.gd +++ b/quiz/player_name_field.gd @@ -1,18 +1,28 @@ +class_name PlayerNameField extends TextEdit +## The field for a new player's name. It keeps to one line of at most +## [constant MAX_NAME_LENGTH] characters, and reports Enter so the player can be added. + +## Emitted when Enter is pressed while the field has focus. signal enter_key_pressed + +## Emitted when Alt+A is pressed. signal close_pressed -@export -var LIMIT: int = 10 -var current_text = '' -var cursor_line = 0 -var cursor_column = 0 +## The longest name a player can have. +const MAX_NAME_LENGTH: int = 10 -func _ready(): +var current_text: String = "" +var cursor_line: int = 0 +var cursor_column: int = 0 + + +func _ready() -> void: text_changed.connect(_on_text_changed) -func _input(event): + +func _input(event: InputEvent) -> void: if event is InputEventKey and event.pressed: if event.alt_pressed && event.keycode == KEY_A: close_pressed.emit() @@ -20,15 +30,16 @@ func _input(event): if event.keycode == KEY_ENTER || event.keycode == KEY_KP_ENTER: enter_key_pressed.emit() -func _on_text_changed(): + +func _on_text_changed() -> void: if text.contains("\n"): text = text.replace("\n", "") - - var new_text : String = text - if new_text.length() > LIMIT: + + var new_text: String = text + if new_text.length() > MAX_NAME_LENGTH: text = current_text # when replacing the text, the cursor will get moved to the beginning of the - # text, so move it back to where it was + # text, so move it back to where it was set_caret_line(cursor_line) set_caret_column(cursor_column) diff --git a/PlayerNameField.gd.uid b/quiz/player_name_field.gd.uid similarity index 100% rename from PlayerNameField.gd.uid rename to quiz/player_name_field.gd.uid diff --git a/quiz/player_row.gd b/quiz/player_row.gd new file mode 100644 index 0000000..3fbfe25 --- /dev/null +++ b/quiz/player_row.gd @@ -0,0 +1,78 @@ +class_name PlayerRow +extends Control + +signal change_character_clicked +signal player_removed + +## The player shown in this row. +var player_object: PlayerObject + +## Position in Settings.player_array. Only used for the keybinds: Alt+ +## gives this player a point and Ctrl+Alt+ takes one away. +var list_index: int + +@onready var player_name_field: Label = %Name +@onready var add_button: Button = %AddPoint +@onready var minus_button: Button = %RemovePoint +@onready var character: TextureButton = %Character +@onready var remove_player: TextureButton = %RemovePlayer + + +func _ready() -> void: + add_button.pressed.connect(add_point) + add_button.set_shortcut(create_button_shortcut(list_index, false)) + minus_button.pressed.connect(minus_point) + minus_button.set_shortcut(create_button_shortcut(list_index, true)) + character.pressed.connect(change_character) + remove_player.pressed.connect(func() -> void: player_removed.emit()) + update_score() + set_player_character() + + +func _process(delta: float) -> void: + remove_player.visible = Settings.edit_players + + +func create_button_shortcut(index: int, ctrl_pressed: bool) -> Shortcut: + var button_shortcut: Shortcut = Shortcut.new() + var button_event: InputEventKey = InputEventKey.new() + button_event.keycode = KEY_1 + index + button_event.alt_pressed = true + button_event.ctrl_pressed = ctrl_pressed + button_shortcut.events.append(button_event) + return button_shortcut + + +func add_point() -> void: + player_object.add_point() + update_score() + + +func minus_point() -> void: + player_object.minus_point() + update_score() + + +func change_character() -> void: + change_character_clicked.emit() + + +func new_character_selected(character_texture: ImageTexture) -> void: + GameLog.debug("Back in player list with new") + player_object.character = character_texture + set_player_character() + + +func set_player_character() -> void: + character.custom_minimum_size = Vector2(80, 40) + character.ignore_texture_size = true + character.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED + character.texture_normal = player_object.character + + +func update_score() -> void: + if OS.has_feature("debug"): + player_name_field.text = "%d %s: %d" % [list_index + 1, player_object.player_name, + player_object.player_score] + else: + player_name_field.text = player_object.player_name + ": " + str(player_object.player_score) diff --git a/Player.gd.uid b/quiz/player_row.gd.uid similarity index 100% rename from Player.gd.uid rename to quiz/player_row.gd.uid diff --git a/quiz/players_panel.gd b/quiz/players_panel.gd new file mode 100644 index 0000000..f542f78 --- /dev/null +++ b/quiz/players_panel.gd @@ -0,0 +1,84 @@ +class_name PlayersPanel +extends PanelContainer +## The players and their points, with a row for adding new players. +## +## The players themselves live in [code]Settings.player_array[/code]; this panel shows +## them and adds and removes them there. + +## Emitted when a player has been added to [code]Settings.player_array[/code], before +## the list shows it. + +signal player_added(player_object: PlayerObject) + +## Emitted when a player has been removed from [code]Settings.player_array[/code]. +signal player_removed(player_object: PlayerObject) + +## Emitted when the character button in [param row] is pressed. +signal change_character_requested(row: PlayerRow, player_name: String) + +var _player_row: PackedScene = preload("res://quiz/PlayerRow.tscn") + +@onready var add_players_button: TextureButton = %AddPlayersButton +@onready var add_player_container: HBoxContainer = %AddPlayerContainer +@onready var add_player_button: Button = %AddPlayerButton +@onready var new_player_name_field: PlayerNameField = %PlayerNameField +@onready var players_list_container: VBoxContainer = %PlayersListVBoxContainer + + +func _ready() -> void: + add_players_button.pressed.connect(add_players) + add_player_button.pressed.connect(add_player) + new_player_name_field.enter_key_pressed.connect(add_player) + + +## Shows or hides the row for adding players, and the buttons for removing them. +func add_players() -> void: + GameLog.debug("add_players") + Settings.edit_players = !Settings.edit_players + add_player_container.visible = !add_player_container.visible + new_player_name_field.grab_focus() + + +## Adds a player with the name in the name field, unless it is empty or there are +## already as many players as there can be. +func add_player() -> void: + GameLog.debug("add_player") + if new_player_name_field.text != "" \ + && Settings.player_array.size() < Settings.MAX_NUMBER_OF_PLAYERS: + var new_player_object: PlayerObject = PlayerObject.new(new_player_name_field.text) + new_player_name_field.text = "" + Settings.player_array.append(new_player_object) + player_added.emit(new_player_object) + reload() + + +## Shows the players in [code]Settings.player_array[/code] again, e.g. after their +## points were reset. +func reload() -> void: + GameLog.debug("load_players") + Settings.delete_children(players_list_container) + var counter: int = 0 + for player_object: PlayerObject in Settings.player_array: + GameLog.debug(player_object.player_name) + + var new_player: PlayerRow = _player_row.instantiate() + new_player.player_object = player_object + new_player.list_index = counter + + new_player.change_character_clicked.connect( + _on_change_character_clicked.bind(new_player, player_object.player_name)) + new_player.player_removed.connect(_on_player_removed.bind(player_object)) + + players_list_container.add_child(new_player) + counter += 1 + + +func _on_change_character_clicked(row: PlayerRow, player_name: String) -> void: + change_character_requested.emit(row, player_name) + + +func _on_player_removed(player_object: PlayerObject) -> void: + GameLog.debug("_on_player_removed ", player_object.player_name) + Settings.player_array.erase(player_object) + reload() + player_removed.emit(player_object) diff --git a/quiz/players_panel.gd.uid b/quiz/players_panel.gd.uid new file mode 100644 index 0000000..509afde --- /dev/null +++ b/quiz/players_panel.gd.uid @@ -0,0 +1 @@ +uid://ctayn0soyatfk diff --git a/quiz/quiz.gd b/quiz/quiz.gd new file mode 100644 index 0000000..2ae7907 --- /dev/null +++ b/quiz/quiz.gd @@ -0,0 +1,298 @@ +class_name Quiz +extends Control +## The quiz: the song that is playing, the playlist, the players and their points, and +## the popups opened from here (settings, character select, winner and log). +## +## The main window shows the quiz below its top bar. Pictures and buttons keep the +## positions they had in the main window, so the quiz fills the whole window and lets +## clicks through where it has nothing. + +## Emitted when another MusicServer is picked in the settings, after the quiz has +## reloaded from it. + +signal server_changed + +## Emitted with a line for the game log, e.g. when a player gets a point. +signal event_logged(text: String) + +var songs: Array = [] +var current_player_row: PlayerRow + +## True while statistics are off because a player won, until points are reset. +var stats_paused_by_win: bool = false + +## Dims the window behind an open popup and keeps clicks from reaching the buttons under it. +var popup_backdrop: PopupBackdrop + +@onready var open_button: Button = $Open +@onready var file_dialog: FileDialog = $FileDialog +@onready var inspiration_list: InspirationList = $InspirationList +@onready var now_playing: NowPlaying = $NowPlaying +@onready var players_panel: PlayersPanel = $PlayersPanel +@onready var toolbar: QuizToolbar = $QuizToolbar +@onready var settings_popup: SettingsPopup = $SettingsPopup +@onready var winner_popup: WinnerPopup = %WinnerPopup +@onready var song_controls: SongControls = $SongControls +@onready var song_loader: SongLoader = $SongLoader +@onready var music_list_panel: MusicListPanel = $MusicListPanel +@onready var character_select_popup: CharacterSelectPopup = %CharacterSelectPopup +@onready var music_player: MusicPlayer = $MusicPlayer +@onready var debug_label: Label = $DebugLabel + + +func _ready() -> void: + popup_backdrop = PopupBackdrop.add_to(self, _popups()) + if OS.has_feature("debug"): + GameLog.debug("is_debug") + debug_label.visible = true + Settings.is_debug = true + settings_popup.set_winning_score(5) + settings_popup.set_selected_server(4) + song_controls.next_pressed.connect(play_next_song) + song_controls.show_answer_pressed.connect(show_answer_pressed) + toolbar.sound_test_pressed.connect(music_player.get_sound_test_song) + toolbar.settings_pressed.connect(show_settings) + toolbar.reset_playlist_pressed.connect(reset_playlist) + toolbar.reset_points_pressed.connect(reset_points) + song_loader.song_list_loaded.connect(update_song_list) + song_loader.song_added.connect(_on_song_added) + + character_select_popup.character_selected.connect(_on_new_character_selected) + music_list_panel.song_clicked.connect(song_object_clicked) + players_panel.player_added.connect(_on_player_added) + players_panel.player_removed.connect(_on_player_removed) + players_panel.change_character_requested.connect(_on_change_character_requested) + music_player.play_next_song.connect(play_next_song) + music_player.update_song_list.connect(update_song_list) + settings_popup.server_changed.connect(_on_server_changed) + settings_popup.visibility_changed.connect(_on_settings_popup_visibility_changed) + + open_button.pressed.connect(open) + + inspiration_list.load_from_server() + song_loader.load_song_list() + character_select_popup.load_characters() + + +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed: + if event.alt_pressed && event.keycode == KEY_UP: + GameLog.debug("Alt + UP pressed") + music_player.change_volume(0.05) + if event.alt_pressed && event.keycode == KEY_DOWN: + GameLog.debug("Alt + DOWN pressed") + music_player.change_volume(-0.05) + if event.alt_pressed && event.ctrl_pressed && event.keycode == KEY_LEFT: + GameLog.debug("Ctrl + Alt + LEFT pressed") + music_player.restart() + elif event.alt_pressed && event.keycode == KEY_LEFT: + GameLog.debug("Alt + LEFT pressed") + music_player.seek(-5) + if event.alt_pressed && event.keycode == KEY_RIGHT: + GameLog.debug("Alt + RIGHT pressed") + music_player.seek(5) + + +## Disables the buttons that use the song list while the server syncs it. +func lock_for_sync() -> void: + toolbar.set_locked(true) + song_controls.set_locked(true) + + +## Reloads from the server after a sync and starts a new game. +func on_sync_finished() -> void: + refresh_from_server() + toolbar.set_locked(false) + song_controls.set_locked(false) + reset_points() + + +## Reloads the soundtracks, playlist and characters from the server. +func refresh_from_server() -> void: + GameLog.debug("refresh_from_server") + inspiration_list.load_from_server() + reset_playlist() + character_select_popup.load_characters() + + +func _on_server_changed() -> void: + GameLog.debug("_on_server_changed") + refresh_from_server() + reset_points() + server_changed.emit() + + +func show_answer_pressed() -> void: + GameLog.debug("show_answer_pressed") + Playlist.song_is_answered() + update_song_list() + + +func play_next_song() -> void: + GameLog.debug("play_next_song ", Playlist.latest_played_song) + if Playlist.first_song_played: + Playlist.set_latest_played_song() + Playlist.first_song_played = true + music_player.play_song_object(Playlist.latest_played_song) + if !(Playlist.get_preloaded_songs() > 0): + song_controls.set_next_enabled(false) + song_loader.fetch_song_if_idle() + + +func _popups() -> Array[BasePopup]: + return [settings_popup, character_select_popup, winner_popup] + + +# The cache size can be changed in the settings. Waiting until the settings are closed +# avoids starting downloads while the user is still clicking the number up and down. +func _on_settings_popup_visibility_changed() -> void: + if !settings_popup.visible: + song_loader.fill_cache() + + +func _on_song_added() -> void: + song_controls.set_next_enabled(true) + if Settings.is_debug: + update_song_list() + + +func update_song_list() -> void: + GameLog.debug("update_song_list") + if Playlist.currently_playing_song >= 0: + now_playing.show_song(Playlist.get_current_soundtrack_title(), + Playlist.get_current_song_title()) + music_list_panel.refresh() + + +func song_object_clicked(song_no: int) -> void: + GameLog.debug("Clicked Song Object: ", Playlist.song_object_array[song_no].to_string()) + if !Settings.character_select_open: + if Playlist.get_song(song_no) == null: + GameLog.debug("has no song, need to download") + var song_downloaded: Callable = func song_downloaded(body: PackedByteArray) -> void: + var sound: AudioStream = AudioStreamMP3.new() + sound.data = body + Playlist.add_song_to_song_object(song_no, sound) + music_player.play_song_object(song_no) + MusicServerClient.fetch_song(song_no, song_downloaded) + else: + GameLog.debug("song already downloaded") + music_player.play_song_object(song_no) + + +func reset_playlist() -> void: + GameLog.debug("reset_playlist") + var playlist_reset_done: Callable = func playlist_reset_done() -> void: + Playlist.reset_playlist() + song_loader.load_song_list() + + MusicServerClient.reset_playlist(playlist_reset_done) + + +func reset_points() -> void: + GameLog.debug("reset_points") + for player_objects: PlayerObject in Settings.player_array: + player_objects.reset_points() + players_panel.reload() + # A new game starts, so turn statistics back on if winning turned them off + if stats_paused_by_win: + Settings.add_to_stats = true + stats_paused_by_win = false + + +func show_settings() -> void: + GameLog.debug("show_settings") + settings_popup.open() + + +# Each new player's points reach the music player, the log and the winner popup. +func _on_player_added(new_player_object: PlayerObject) -> void: + new_player_object.first_point_triggered.connect(music_player._on_point_triggered.bind("first")) + new_player_object.match_point_triggered.connect(music_player._on_point_triggered.bind("match")) + new_player_object.winner_triggered.connect(_on_player_won.bind(new_player_object)) + new_player_object.point_given_sound.connect(_on_make_point_given_sound) + new_player_object.player_point_given.connect(_on_point_given.bind(new_player_object)) + new_player_object.player_point_taken.connect(_on_point_taken.bind(new_player_object)) + + +func _on_point_given(player_object: PlayerObject) -> void: + GameLog.debug("_on_point_given") + event_logged.emit(player_object.player_name + " got a point") + if Playlist.currently_playing_song >= 0: + Playlist.add_point(player_object.id) + update_song_list() + + +func _on_make_point_given_sound() -> void: + music_player.play_sound_effect(preload("res://sounds/itemopen.wav")) + + +func _on_point_taken(player_object: PlayerObject) -> void: + GameLog.debug("_on_point_taken") + event_logged.emit(player_object.player_name + " lost a point") + music_player.play_sound_effect(preload("res://sounds/itemequip.wav")) + if Playlist.currently_playing_song >= 0: + Playlist.remove_point(player_object.id) + update_song_list() + + +func _on_player_won(player_object: PlayerObject) -> void: + GameLog.debug("_on_player_won") + winner_popup.show_winner(player_object) + music_player.play_song(preload("res://sounds/winning.mp3")) + # Songs played after the game is won don't count towards the statistics + if Settings.add_to_stats: + Settings.add_to_stats = false + stats_paused_by_win = true + + +func _on_player_removed(player_object: PlayerObject) -> void: + Playlist.remove_player_from_list(player_object.id) + update_song_list() + + +func _on_change_character_requested(new_player: PlayerRow, player_name: String) -> void: + GameLog.debug("_on_player_change_character_clicked") + current_player_row = new_player + GameLog.debug("Change character for: " + player_name) + character_select_popup.show_grid(player_name) + + +func _on_new_character_selected(character_texture: ImageTexture) -> void: + GameLog.debug("_on_new_character_selected") + current_player_row.new_character_selected(character_texture) + + +###Local + +var local_path: String = '/Users/sebastian/ResilioSync/Sorterat_test/Metal Gear Solid 4 - Guns of the Patriots/2-16 Metal Gear Saga.mp3' + + +func open() -> void: + file_dialog.popup() + + +func _on_file_dialog_dir_selected(path: String) -> void: + GameLog.debug(path) + dir_contents(path) + + +func dir_contents(path: String) -> void: + var dir: DirAccess = DirAccess.open(path) + if dir: + dir.list_dir_begin() + var file_name: String = dir.get_next() + songs.clear() + var soundtracks: Array = [] + while file_name != "": + if dir.current_is_dir(): + #GameLog.debug("Found directory: " + file_name) + soundtracks.append(file_name) + else: + #GameLog.debug("Found file: " + file_name) + if file_name.ends_with(".mp3"): + songs.append(path + "/" + file_name) + file_name = dir.get_next() + inspiration_list.show_soundtracks(soundtracks) + else: + GameLog.warning("An error occurred when trying to access the path.") diff --git a/quiz/quiz.gd.uid b/quiz/quiz.gd.uid new file mode 100644 index 0000000..3af990a --- /dev/null +++ b/quiz/quiz.gd.uid @@ -0,0 +1 @@ +uid://b0od47vseenx4 diff --git a/quiz/quiz_toolbar.gd b/quiz/quiz_toolbar.gd new file mode 100644 index 0000000..81818f3 --- /dev/null +++ b/quiz/quiz_toolbar.gd @@ -0,0 +1,25 @@ +class_name QuizToolbar +extends Control +## The row of quiz buttons under the top bar: reset playlist, reset points, settings +## and sound test. + +signal reset_playlist_pressed +signal reset_points_pressed +signal settings_pressed +signal sound_test_pressed + +@onready var reset_playlist_button: Button = $ResetPlaylistButton +@onready var sound_test_button: Button = $SoundTestButton + + +func _ready() -> void: + reset_playlist_button.pressed.connect(reset_playlist_pressed.emit) + $ResetPointsButton.pressed.connect(reset_points_pressed.emit) + $SettingsButton.pressed.connect(settings_pressed.emit) + sound_test_button.pressed.connect(sound_test_pressed.emit) + + +## Disables the buttons that use the song list, e.g. while the server syncs it. +func set_locked(locked: bool) -> void: + reset_playlist_button.disabled = locked + sound_test_button.disabled = locked diff --git a/quiz/quiz_toolbar.gd.uid b/quiz/quiz_toolbar.gd.uid new file mode 100644 index 0000000..8d5020b --- /dev/null +++ b/quiz/quiz_toolbar.gd.uid @@ -0,0 +1 @@ +uid://dmsy36s1ht7x5 diff --git a/quiz/scroll_to_end_container.gd b/quiz/scroll_to_end_container.gd new file mode 100644 index 0000000..38f5316 --- /dev/null +++ b/quiz/scroll_to_end_container.gd @@ -0,0 +1,12 @@ +class_name ScrollToEndContainer +extends ScrollContainer +## A scroll container that scrolls to the end whenever its content grows, so the newest +## item is always in view. + +var max_value: float = 0.0 + + +func _draw() -> void: + if max_value != get_v_scroll_bar().max_value: + max_value = get_v_scroll_bar().max_value + scroll_vertical = int(max_value) diff --git a/MusicListScrollContainer.gd.uid b/quiz/scroll_to_end_container.gd.uid similarity index 100% rename from MusicListScrollContainer.gd.uid rename to quiz/scroll_to_end_container.gd.uid diff --git a/quiz/song_controls.gd b/quiz/song_controls.gd new file mode 100644 index 0000000..26d9fe0 --- /dev/null +++ b/quiz/song_controls.gd @@ -0,0 +1,27 @@ +class_name SongControls +extends Control +## The buttons for the song that is playing: show the answer, and go to the next song. + +signal show_answer_pressed +signal next_pressed + +@onready var show_answer_button: Button = $ShowAnswerButton +@onready var next_button: Button = $NextButton + + +func _ready() -> void: + show_answer_button.pressed.connect(show_answer_pressed.emit) + next_button.pressed.connect(next_pressed.emit) + + +## Enables Next while there is a downloaded song to play next. +func set_next_enabled(enabled: bool) -> void: + next_button.disabled = !enabled + + +## Disables both buttons, e.g. while the server syncs the song list. Unlocking enables +## Show answer again; Next comes back when the next song has been downloaded. +func set_locked(locked: bool) -> void: + show_answer_button.disabled = locked + if locked: + next_button.disabled = true diff --git a/quiz/song_controls.gd.uid b/quiz/song_controls.gd.uid new file mode 100644 index 0000000..94131e8 --- /dev/null +++ b/quiz/song_controls.gd.uid @@ -0,0 +1 @@ +uid://d6nevh66yaw1 diff --git a/quiz/song_loader.gd b/quiz/song_loader.gd new file mode 100644 index 0000000..ce85697 --- /dev/null +++ b/quiz/song_loader.gd @@ -0,0 +1,101 @@ +class_name SongLoader +extends Node +## Downloads songs from the server into the Playlist ahead of time, so the next song +## can start at once. +## +## How many songs it keeps ready is the cache size in the settings +## ([code]Playlist.number_of_tracks_to_preload[/code]). + +## Emitted when the songs already in the server playlist have been loaded into the +## Playlist. + +signal song_list_loaded + +## Emitted when a new song has been downloaded and added to the Playlist. +signal song_added + +## Seconds to wait before retrying a download step that failed. +var retry_seconds: float = 5.0 + + +## Loads the songs already in the server playlist, then starts preloading new ones. +## Preloading waits for the list, because loading the list clears the playlist. +func load_song_list() -> void: + GameLog.debug("load_song_list") + var song_list_fetched: Callable = func song_list_fetched(data: Variant) -> void: + Playlist.clear_song_list() + if typeof(data) == TYPE_ARRAY: + for d: Dictionary in data: + var new_song_object: SongObject = SongObject.new() + new_song_object.song_number = d.SongNo + new_song_object.soundtrack_title = d.Soundtrack + new_song_object.song_title = d.Song + new_song_object.is_answered = true + new_song_object.has_played = true + Playlist.add_to_playlist(new_song_object) + elif data != null: + GameLog.warning("Unexpected data") + Playlist.update_last_played_song() + song_list_loaded.emit() + fetch_song_if_idle() + MusicServerClient.fetch_song_list(song_list_fetched, _retry_later.bind(load_song_list)) + + +## Starts downloading one more song, which keeps downloading until the cache is full. +func fetch_song() -> void: + GameLog.debug("fetch_song") + Playlist.is_song_downloading = true + _download_random_song(SongObject.new()) + + +## Starts downloading a song unless one is already downloading. +func fetch_song_if_idle() -> void: + if !Playlist.is_song_downloading: + fetch_song() + + +## Starts downloading if nothing is downloading and the cache is not full, e.g. after +## the cache size was raised. +func fill_cache() -> void: + if !Playlist.is_song_downloading && Playlist.should_preload_more_songs(): + fetch_song() + + +# Fetching a song takes three requests. If one fails, only that step is retried, so +# the server and the game keep the same songs in their playlists. +func _download_random_song(new_song_object: SongObject) -> void: + var song_downloaded: Callable = func song_downloaded(body: PackedByteArray) -> void: + GameLog.debug("song_downloaded") + var sound: AudioStream = AudioStreamMP3.new() + sound.data = body + new_song_object.song = sound + _add_song_to_queue(new_song_object) + MusicServerClient.fetch_random_song(Settings.use_low_played_mode, song_downloaded, + _retry_later.bind(_download_random_song.bind(new_song_object))) + + +func _add_song_to_queue(new_song_object: SongObject) -> void: + MusicServerClient.add_to_queue(_fetch_song_info.bind(new_song_object), + _retry_later.bind(_add_song_to_queue.bind(new_song_object))) + + +func _fetch_song_info(new_song_object: SongObject) -> void: + var song_info_fetched: Callable = func song_info_fetched(data_received: Dictionary) -> void: + GameLog.debug("song_info_fetched") + new_song_object.song_number = data_received.SongNo + new_song_object.soundtrack_title = data_received.Soundtrack + new_song_object.song_title = data_received.Song + GameLog.debug("new_song_object: ", new_song_object.to_string()) + Playlist.add_to_playlist(new_song_object) + if Playlist.should_preload_more_songs(): + fetch_song() + else: + Playlist.is_song_downloading = false + song_added.emit() + MusicServerClient.fetch_song_info(song_info_fetched, + _retry_later.bind(_fetch_song_info.bind(new_song_object))) + + +func _retry_later(step: Callable) -> void: + GameLog.warning("Song download failed, retrying in %s seconds" % retry_seconds) + get_tree().create_timer(retry_seconds).timeout.connect(step) diff --git a/quiz/song_loader.gd.uid b/quiz/song_loader.gd.uid new file mode 100644 index 0000000..ced5424 --- /dev/null +++ b/quiz/song_loader.gd.uid @@ -0,0 +1 @@ +uid://dwqirg7x7mmj6 diff --git a/quiz/song_row.gd b/quiz/song_row.gd new file mode 100644 index 0000000..b0b433b --- /dev/null +++ b/quiz/song_row.gd @@ -0,0 +1,37 @@ +class_name SongRow +extends PanelContainer +## One song in the music list: its number, soundtrack and title, and a marker for +## each player who got a point for it. + +var song_object: SongObject + +@onready var song_list_label: Label = %SongListLabel +@onready var song_list_players: HBoxContainer = %SongListPlayers + + +func _ready() -> void: + set_label() + update_players() + + +func set_label() -> void: + if song_object != null: + var size_to_change: int = song_object.players_given_point.size() * 50 + song_list_label.custom_minimum_size = Vector2(1000 - size_to_change, 28) + if song_object.is_playing: + song_list_label.theme_type_variation = &"SongRowLabelPlaying" + song_list_label.text = song_object.get_song_info() + + +func update_players() -> void: + Settings.delete_children(song_list_players) + if song_object != null: + # Show players in the same order as the player list + for player: PlayerObject in Settings.player_array: + if song_object.players_given_point.has(player.id): + var texture: TextureRect = TextureRect.new() + texture.texture = player.character + texture.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL + texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + texture.custom_minimum_size = Vector2(50, 25) + song_list_players.add_child(texture) diff --git a/song_list_object.gd.uid b/quiz/song_row.gd.uid similarity index 100% rename from song_list_object.gd.uid rename to quiz/song_row.gd.uid diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..f4f04d2 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Runs the GdUnit4 test suite headless. +# +# Usage: ./run_tests.sh [test path ...] +# ./run_tests.sh all tests +# ./run_tests.sh res://tests/unit only unit tests +# ./run_tests.sh res://tests/unit/test_search.gd a single suite +# +# Godot is taken from $GODOT, then `godot` on PATH, then the macOS app bundles. + +set -euo pipefail +cd "$(dirname "$0")" + +find_godot() { + if [[ -n "${GODOT:-}" ]]; then + echo "$GODOT" + return + fi + if command -v godot > /dev/null; then + command -v godot + return + fi + for app in /Applications/Godot.app /Applications/Godot_mono.app; do + if [[ -x "$app/Contents/MacOS/Godot" ]]; then + echo "$app/Contents/MacOS/Godot" + return + fi + done + echo "Godot not found. Install Godot 4.7 or set GODOT=/path/to/godot" >&2 + exit 1 +} + +godot_bin="$(find_godot)" + +paths=("$@") +if [[ ${#paths[@]} -eq 0 ]]; then + paths=(res://tests/unit res://tests/integration) +fi +test_args=() +for path in "${paths[@]}"; do + test_args+=(-a "$path") +done + +# Builds the class cache so class_name types resolve; fast when nothing changed +"$godot_bin" --headless --path . --import > /dev/null 2>&1 + +set +e +"$godot_bin" --headless --path . \ + -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode "${test_args[@]}" +exit_code=$? +set -e + +# GdUnit4 exits with 101 when tests pass but report warnings (e.g. orphan nodes) +if [[ $exit_code -eq 101 ]]; then + echo "Tests passed with warnings, see the report above." + exit 0 +fi +exit $exit_code diff --git a/server/http_transport.gd b/server/http_transport.gd new file mode 100644 index 0000000..63cc4ad --- /dev/null +++ b/server/http_transport.gd @@ -0,0 +1,42 @@ +class_name HttpTransport +extends RefCounted +## Sends HTTP requests for [code]MusicServerClient[/code] using [HTTPRequest] nodes. +## +## Tests subclass this to answer requests without a network connection. + +## Seconds before a request is given up and reported as failed. This covers the whole +## download, so it is generous enough for long songs on a slow connection. + +var timeout_seconds: float = 120.0 +var _owner: Node + + +func _init(owner: Node) -> void: + _owner = owner + + +## Requests [param url] and calls [param on_complete] with +## [code](response_code: int, body: PackedByteArray)[/code]. Connection errors and +## timeouts are reported with response code 0. +func request(url: String, method: int, on_complete: Callable) -> void: + var http_request: HTTPRequest = HTTPRequest.new() + http_request.timeout = timeout_seconds + _owner.add_child(http_request) + http_request.request_completed.connect( + func( + result: int, + response_code: int, + _headers: PackedStringArray, + body: PackedByteArray, + ) -> void: + http_request.queue_free() + on_complete.call(response_code if result == HTTPRequest.RESULT_SUCCESS else 0, body) + ) + var headers: PackedStringArray = [] + if method == HTTPClient.METHOD_PUT: + headers.append("Content-Type: application/json") + var error: int = http_request.request(url, headers, method) + if error != OK: + http_request.queue_free() + push_error("Could not send request to %s (error %d)" % [url, error]) + on_complete.call(0, PackedByteArray()) diff --git a/server/http_transport.gd.uid b/server/http_transport.gd.uid new file mode 100644 index 0000000..13bf9e1 --- /dev/null +++ b/server/http_transport.gd.uid @@ -0,0 +1 @@ +uid://uinuvqrxeppl diff --git a/server/music_server_client.gd b/server/music_server_client.gd new file mode 100644 index 0000000..3f83292 --- /dev/null +++ b/server/music_server_client.gd @@ -0,0 +1,140 @@ +extends Node +## Client for the MusicServer REST API. +## +## Registered as the [code]MusicServerClient[/code] autoload, so every script can call it +## directly, e.g. [code]MusicServerClient.fetch_song_list(on_done)[/code]. All HTTP traffic +## goes through [member transport]; tests replace it with a fake to answer requests +## without a running server. + +## Sends the actual HTTP requests. Swap it out in tests. + +var transport: HttpTransport + + +func _ready() -> void: + if transport == null: + transport = HttpTransport.new(self) + + +## Fetches the songs already in the server playlist, as an Array of +## [code]{SongNo, Soundtrack, Song}[/code] dictionaries. +func fetch_song_list(on_done: Callable, on_failed: Callable = Callable()) -> void: + get_json("/music/list", on_done, on_failed) + + +## Downloads a random song as MP3 bytes. With [param low_played] the server prefers +## soundtracks that have been played the least. +func fetch_random_song( + low_played: bool, on_done: Callable, on_failed: Callable = Callable()) -> void: + get_bytes("/music/rand/low" if low_played else "/music/rand", on_done, on_failed) + + +## Adds the most recently fetched random song to the server playlist. +func add_to_queue(on_done: Callable, on_failed: Callable = Callable()) -> void: + send("/music/addQue", on_done, on_failed) + + +## Fetches [code]{SongNo, Soundtrack, Song}[/code] for the most recently queued song. +func fetch_song_info(on_done: Callable, on_failed: Callable = Callable()) -> void: + get_json("/music/info", on_done, on_failed) + + +## Downloads a song from the server playlist as MP3 bytes. +func fetch_song(song_no: int, on_done: Callable, on_failed: Callable = Callable()) -> void: + get_bytes("/music?song=" + str(song_no), on_done, on_failed) + + +## Downloads the sound test song as MP3 bytes. +func fetch_sound_test_song(on_done: Callable, on_failed: Callable = Callable()) -> void: + get_bytes("/music/soundTest", on_done, on_failed) + + +## Clears the server playlist. +func reset_playlist(on_done: Callable, on_failed: Callable = Callable()) -> void: + send("/music/reset", on_done, on_failed) + + +## Fetches every soundtrack name as an Array of Strings. +func fetch_all_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> void: + get_json("/music/all", on_done, on_failed) + + +## Fetches every soundtrack name, sorted, as an Array of Strings. +func fetch_all_soundtracks_ordered(on_done: Callable, on_failed: Callable = Callable()) -> void: + get_json("/music/all/order", on_done, on_failed) + + +## Records in the server statistics that a song has been played. +func mark_song_played(song_no: int) -> void: + send("/music/played?song=" + str(song_no), Callable(), Callable(), HTTPClient.METHOD_PUT) + + +## Fetches the names of all character images. +func fetch_character_list(on_done: Callable, on_failed: Callable = Callable()) -> void: + get_json("/characters", on_done, on_failed) + + +## Downloads a character image as PNG bytes. +func fetch_character_image( + character_name: String, on_done: Callable, on_failed: Callable = Callable()) -> void: + get_bytes("/character?name=" + character_name.uri_encode(), on_done, on_failed) + + +## Starts syncing the server's soundtrack library with its music folder. +func start_sync(on_done: Callable, on_failed: Callable = Callable()) -> void: + send("/sync", on_done, on_failed) + + +## Fetches the sync state: [code]{progress, time_spent}[/code] while running, or the +## sync result once finished. +func fetch_sync_progress(on_done: Callable, on_failed: Callable = Callable()) -> void: + get_json("/sync/progress", on_done, on_failed) + + +## Requests [param path] and calls [param on_done] with the parsed JSON. +## [param on_failed] is called with no arguments if the request fails or the body is +## not valid JSON. +func get_json(path: String, on_done: Callable, on_failed: Callable = Callable()) -> void: + _request(path, HTTPClient.METHOD_GET, func(body: PackedByteArray) -> void: + var json: JSON = JSON.new() + if json.parse(body.get_string_from_utf8()) != OK: + push_warning("Invalid JSON from %s: %s" % [path, json.get_error_message()]) + _call_if_valid(on_failed) + return + on_done.call(json.data) + , on_failed) + + +## Requests [param path] and calls [param on_done] with the raw response body, or +## [param on_failed] with no arguments if the request fails. +func get_bytes(path: String, on_done: Callable, on_failed: Callable = Callable()) -> void: + _request(path, HTTPClient.METHOD_GET, on_done, on_failed) + + +## Requests [param path] and calls [param on_done] with no arguments when it succeeds, +## or [param on_failed] when it fails. Either may be an empty Callable. +func send( + path: String, + on_done: Callable, + on_failed: Callable = Callable(), + method: int = HTTPClient.METHOD_GET, +) -> void: + _request(path, method, func(_body: PackedByteArray) -> void: + _call_if_valid(on_done) + , on_failed) + + +func _request(path: String, method: int, on_success: Callable, on_failed: Callable) -> void: + var url: String = Settings.default_path + path + transport.request(url, method, func(response_code: int, body: PackedByteArray) -> void: + if response_code != 200: + push_warning("Request to %s failed with response code %d" % [url, response_code]) + _call_if_valid(on_failed) + return + on_success.call(body) + ) + + +static func _call_if_valid(callback: Callable) -> void: + if callback.is_valid(): + callback.call() diff --git a/server/music_server_client.gd.uid b/server/music_server_client.gd.uid new file mode 100644 index 0000000..f382d06 --- /dev/null +++ b/server/music_server_client.gd.uid @@ -0,0 +1 @@ +uid://cbb0fpkj7k38w diff --git a/song_list_object.gd b/song_list_object.gd deleted file mode 100644 index fa29cce..0000000 --- a/song_list_object.gd +++ /dev/null @@ -1,50 +0,0 @@ -class_name SongListObject -extends PanelContainer - -@onready -var song_list_label: Label = $HBoxContainer/SongListLabel - -@onready -var song_list_players: HBoxContainer = $HBoxContainer/SongListPlayers - -var song_object: SongObject - -#Color(0.18, 0.18, 0.18) -#Color(0.302, 0.302, 0.302) -#Color(0.259, 0.259, 0.259) - -# Called when the node enters the scene tree for the first time. -func _ready() -> void: - #if song_object != null && song_object.song_number % 2 == 0: - # var new_stylebox_normal = self.get_theme_stylebox("panel").duplicate() - # new_stylebox_normal.bg_color = Color(0.18, 0.18, 0.18) - # self.add_theme_stylebox_override("panel", new_stylebox_normal) - # song_list_label.add_theme_stylebox_override("normal", new_stylebox_normal) - #else: - # var new_stylebox_normal = self.get_theme_stylebox("panel").duplicate() - # new_stylebox_normal.bg_color = Color(0.18, 0.18, 0.18) - # self.add_theme_stylebox_override("panel", new_stylebox_normal) - # song_list_label.add_theme_stylebox_override("normal", new_stylebox_normal) - set_label() - update_players() - -func set_label() -> void: - if song_object != null: - var size_to_change: int = song_object.players_given_point.size() * 50 - song_list_label.custom_minimum_size = Vector2(1000 - size_to_change, 28) - if song_object.is_playing: - song_list_label.add_theme_color_override("font_color", Color(1, 0.5, 0)) - song_list_label.text = song_object.get_song_info() - -func update_players() -> void: - Settings.delete_children(song_list_players) - if song_object != null: - for player_id: int in song_object.players_given_point: - for player: PlayerObject in Settings.player_array: - if player.id == player_id: - var texture: TextureRect = TextureRect.new() - texture.texture = player.character - texture.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL - texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - texture.custom_minimum_size = Vector2(50, 25) - song_list_players.add_child(texture) diff --git a/style_box_flat.tres b/style_box_flat.tres deleted file mode 100644 index 7aecf29..0000000 --- a/style_box_flat.tres +++ /dev/null @@ -1,4 +0,0 @@ -[gd_resource type="StyleBoxFlat" format=3 uid="uid://bsv6yb0yir04l"] - -[resource] -bg_color = Color(0.180392, 0.180392, 0.180392, 1) diff --git a/sync_response.gd b/sync_response.gd deleted file mode 100644 index e765337..0000000 --- a/sync_response.gd +++ /dev/null @@ -1,10 +0,0 @@ -class_name SyncResponse -extends Node - -var soundtracks_added: Array -var soundtracks_re_added: Array -var soundtracks_changed_title: Array -var soundtracks_changed_content: Array -var soundtracks_removed: Array -var catched_errors: Array -var total_time: String diff --git a/sync_response.gd.uid b/sync_response.gd.uid deleted file mode 100644 index 6947787..0000000 --- a/sync_response.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dtfsuob2aidl7 diff --git a/tests/data/.gdignore b/tests/data/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/layout_baseline.json b/tests/data/layout_baseline.json new file mode 100644 index 0000000..9f0e3a2 --- /dev/null +++ b/tests/data/layout_baseline.json @@ -0,0 +1,2149 @@ +{ +"add_player_row": { + ".": {"rect":[0.0,0.0,1920.0,1080.0],"visible":true}, + "AboutPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "AboutPopup/AboutPanel": {"rect":[720.0,153.0,600.0,640.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer": {"rect":[740.0,203.0,560.0,580.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox": {"rect":[740.0,203.0,1.0,545.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/DescriptionLabel": {"rect":[740.0,203.0,1.0,491.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/VersionLabel": {"rect":[740.0,203.0,1.0,23.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/WhatsNewLabel": {"rect":[740.0,203.0,1.0,23.0],"visible":false}, + "AboutPopup/AboutPanel/TitleLabel": {"rect":[720.0,153.0,600.0,50.0],"visible":false}, + "LogPopup": {"rect":[562.0,132.0,2620.0,1780.0],"visible":false}, + "LogPopup/LogPanel": {"rect":[562.0,132.0,700.0,700.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer": {"rect":[582.0,188.0,660.0,628.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer": {"rect":[582.0,188.0,0.0,0.0],"visible":false}, + "LogPopup/LogPanel/TitleLabel": {"rect":[562.0,140.0,700.0,40.0],"visible":false}, + "PopupBackdrop": {"rect":[0.0,0.0,1920.0,1080.0],"visible":false}, + "QRPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "QRPopup/QRPanel": {"rect":[720.0,153.0,450.0,520.0],"visible":false}, + "QRPopup/QRPanel/QRCodeRect": {"rect":[740.0,173.0,410.0,410.0],"visible":false}, + "QRPopup/QRPanel/URLLabel": {"rect":[740.0,603.0,410.0,40.0],"visible":false}, + "Quiz": {"rect":[0.0,0.0,1920.0,1080.0],"visible":true}, + "Quiz/CharacterSelectPopup": {"rect":[405.0,165.0,1152.0,640.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel": {"rect":[405.0,165.0,1290.0,665.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer": {"rect":[405.0,165.0,1290.0,659.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer": {"rect":[405.0,165.0,1190.0,620.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer": {"rect":[405.0,165.0,1150.0,600.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer": {"rect":[405.0,165.0,1150.0,600.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/TitleLabel": {"rect":[405.0,165.0,197.0,35.0],"visible":false}, + "Quiz/DebugLabel": {"rect":[974.0,904.0,120.0,69.0],"visible":true}, + "Quiz/FileDialog": {"rect":[0.0,0.0,616.0,400.0],"visible":false}, + "Quiz/InspirationList": {"rect":[1216.0,128.0,641.0,384.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer": {"rect":[1216.0,128.0,641.0,384.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#0": {"rect":[1216.0,128.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#0/#0": {"rect":[1224.0,130.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#1": {"rect":[1216.0,164.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#1/#0": {"rect":[1224.0,166.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#2": {"rect":[1216.0,200.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#2/#0": {"rect":[1224.0,202.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#3": {"rect":[1216.0,236.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#3/#0": {"rect":[1224.0,238.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#4": {"rect":[1216.0,272.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#4/#0": {"rect":[1224.0,274.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#5": {"rect":[1216.0,308.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#5/#0": {"rect":[1224.0,310.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#6": {"rect":[1216.0,344.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#6/#0": {"rect":[1224.0,346.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#7": {"rect":[1216.0,380.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#7/#0": {"rect":[1224.0,382.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#8": {"rect":[1216.0,416.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#8/#0": {"rect":[1224.0,418.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#9": {"rect":[1216.0,452.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#9/#0": {"rect":[1224.0,454.0,625.0,28.0],"visible":true}, + "Quiz/MusicListPanel": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow": {"rect":[64.0,384.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer": {"rect":[64.0,384.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/Panel": {"rect":[1068.0,384.0,80.0,0.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListLabel": {"rect":[64.0,384.0,1000.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListPlayers": {"rect":[1152.0,384.0,0.0,0.0],"visible":true}, + "Quiz/MusicPlayer": {"rect":[64.0,992.0,1792.0,71.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar": {"rect":[65.0,992.0,1791.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer": {"rect":[65.0,992.0,1791.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/MusicPlayerSlider": {"rect":[137.0,1000.0,1532.0,16.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/MusicTimeLabel": {"rect":[1673.0,996.0,79.0,23.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/PlayTextureButton": {"rect":[65.0,992.0,32.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/RestartTextureButton": {"rect":[101.0,992.0,32.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/VolumeSlider": {"rect":[1756.0,1000.0,100.0,16.0],"visible":true}, + "Quiz/MusicPlayer/RepeatSongCheckButton": {"rect":[64.0,1032.0,144.0,31.0],"visible":true}, + "Quiz/NowPlaying": {"rect":[64.0,64.0,1088.0,320.0],"visible":true}, + "Quiz/NowPlaying/SongLabel": {"rect":[64.0,137.0,1088.0,49.0],"visible":true}, + "Quiz/NowPlaying/SoundtrackLabel": {"rect":[64.0,64.0,1088.0,69.0],"visible":true}, + "Quiz/Open": {"rect":[1296.0,66.0,50.0,31.0],"visible":false}, + "Quiz/PlayersPanel": {"rect":[1216.0,576.0,641.0,401.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer": {"rect":[1216.0,576.0,641.0,401.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer": {"rect":[1216.0,620.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/AddPlayerButton": {"rect":[1617.0,620.0,39.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/Panel": {"rect":[1216.0,620.0,196.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/Panel2": {"rect":[1660.0,620.0,197.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/PlayerNameField": {"rect":[1416.0,620.0,197.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer": {"rect":[1216.0,576.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer/AddPlayersButton": {"rect":[1821.0,576.0,36.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer/Label": {"rect":[1216.0,576.0,601.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer": {"rect":[1216.0,664.0,641.0,313.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer": {"rect":[1216.0,664.0,641.0,313.0],"visible":true}, + "Quiz/PopupBackdrop": {"rect":[0.0,0.0,1920.0,1080.0],"visible":false}, + "Quiz/QuizToolbar": {"rect":[1360.0,64.0,495.0,31.0],"visible":true}, + "Quiz/QuizToolbar/ResetPlaylistButton": {"rect":[1360.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/ResetPointsButton": {"rect":[1488.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/SettingsButton": {"rect":[1616.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/SoundTestButton": {"rect":[1744.0,64.0,111.0,31.0],"visible":true}, + "Quiz/SettingsPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel": {"rect":[720.0,153.0,500.0,640.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer": {"rect":[740.0,161.0,460.0,624.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer": {"rect":[740.0,161.0,296.0,490.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/AddToDatabaseCheckButton": {"rect":[740.0,161.0,260.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer": {"rect":[740.0,161.0,280.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/CacheLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/CacheTitleLabel": {"rect":[740.0,161.0,160.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/IncreaseCacheButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/LowerCacheButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/FullscreenButton": {"rect":[740.0,161.0,127.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer": {"rect":[740.0,161.0,239.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/IncreaseButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/Label2": {"rect":[740.0,161.0,119.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/LowerButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/ScoreLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideBeginningCheckButton": {"rect":[740.0,161.0,233.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer": {"rect":[740.0,161.0,274.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/HideForSecondsLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/HideForSecondsTitle": {"rect":[740.0,161.0,154.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/IncreaseHideForSecondsButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/LowerHideForSecondsButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideLengthCheckButton": {"rect":[740.0,161.0,206.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideNextTrackCheckButton": {"rect":[740.0,161.0,167.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideTicksCheckButton": {"rect":[740.0,161.0,205.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer": {"rect":[740.0,161.0,296.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/IncreaseInspirationSpeedButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/InspirationListSpeedLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/InspirationListSpeedTitle": {"rect":[740.0,161.0,176.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/LowerInspirationSpeedButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/Label": {"rect":[740.0,161.0,97.0,35.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/LocalButton": {"rect":[740.0,161.0,89.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/LowPlayedCheckButton": {"rect":[740.0,161.0,213.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/QuickSyncButton": {"rect":[740.0,161.0,131.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/SelectServerButton": {"rect":[740.0,161.0,267.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/StopAfterCurrentCheckButton": {"rect":[740.0,161.0,187.0,31.0],"visible":false}, + "Quiz/SongControls": {"rect":[65.0,944.0,303.0,31.0],"visible":true}, + "Quiz/SongControls/NextButton": {"rect":[193.0,944.0,175.0,31.0],"visible":true}, + "Quiz/SongControls/ShowAnswerButton": {"rect":[65.0,944.0,111.0,31.0],"visible":true}, + "Quiz/WinnerPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel": {"rect":[720.0,153.0,500.0,600.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer": {"rect":[720.0,178.0,500.0,257.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/PlayersList": {"rect":[720.0,178.0,0.0,0.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/WinnerLabel": {"rect":[720.0,178.0,132.0,49.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/WinnerPicture": {"rect":[720.0,178.0,480.0,200.0],"visible":false}, + "SearchPopup": {"rect":[56.0,96.0,1920.0,1080.0],"visible":false}, + "SearchPopup/ClearButton": {"rect":[992.0,112.0,182.0,80.0],"visible":false}, + "SearchPopup/ScrollContainer": {"rect":[56.0,224.0,1152.0,512.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults": {"rect":[56.0,224.0,0.0,0.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#0": {"rect":[56.0,224.0,2.0,98.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#1": {"rect":[56.0,224.0,2.0,150.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#2": {"rect":[56.0,224.0,2.0,150.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#3": {"rect":[56.0,224.0,2.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#4": {"rect":[56.0,224.0,2.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#5": {"rect":[56.0,224.0,2.0,98.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#6": {"rect":[56.0,224.0,2.0,462.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#7": {"rect":[56.0,224.0,2.0,202.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#8": {"rect":[56.0,224.0,2.0,98.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#9": {"rect":[56.0,224.0,2.0,150.0],"visible":false}, + "SearchPopup/SearchPanel": {"rect":[56.0,96.0,1152.0,640.0],"visible":false}, + "SearchPopup/Searchbar": {"rect":[88.0,112.0,872.0,80.0],"visible":false}, + "ShortcutsPopup": {"rect":[562.0,160.0,2620.0,1780.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#0": {"rect":[562.0,160.0,93.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0/#0": {"rect":[562.0,160.0,1.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0/#1": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0/#1": {"rect":[562.0,160.0,1.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0/#1": {"rect":[562.0,160.0,1.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0/#1": {"rect":[562.0,160.0,1.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0/#1": {"rect":[562.0,160.0,1.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4": {"rect":[562.0,160.0,6.0,214.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0": {"rect":[562.0,160.0,6.0,214.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0/#0": {"rect":[562.0,160.0,1.0,214.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0/#1": {"rect":[562.0,160.0,1.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0/#0": {"rect":[562.0,160.0,1.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0/#1": {"rect":[562.0,160.0,1.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0/#1": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "StatisticsPopupPanel": {"rect":[0.0,0.0,450.0,100.0],"visible":false}, + "StatisticsPopupPanel/Label": {"rect":[12.0,12.0,426.0,76.0],"visible":false}, + "StatisticsPopupPanel/StatisticsLabel": {"rect":[12.0,12.0,426.0,76.0],"visible":false}, + "SyncPopup": {"rect":[210.0,153.0,1920.0,1080.0],"visible":false}, + "SyncPopup/SyncPanel": {"rect":[210.0,153.0,1500.0,640.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer": {"rect":[230.0,161.0,1460.0,624.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer": {"rect":[230.0,161.0,84.0,70.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox/CaughtErrorsLabel": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/NoChangesLabel": {"rect":[230.0,161.0,319.0,35.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer": {"rect":[230.0,161.0,1300.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer/SyncProgressBar": {"rect":[230.0,161.0,1300.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/RestartSyncButton": {"rect":[230.0,161.0,200.0,80.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer": {"rect":[230.0,161.0,1071.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox/SoundtracksAddedLabel": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox/SoundtracksChangedContentLabel": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox/SoundtracksChangedTitleLabel": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox/SoundtracksReAddedLabel": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox/SoundtracksRemovedLabel": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTime": {"rect":[230.0,161.0,84.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTitleLabel": {"rect":[230.0,161.0,55.0,35.0],"visible":false}, + "TopBar": {"rect":[0.0,0.0,1920.0,56.0],"visible":true}, + "TopBar/AboutButton": {"rect":[1722.0,8.0,70.0,40.0],"visible":true}, + "TopBar/QRButton": {"rect":[1802.0,8.0,70.0,40.0],"visible":true}, + "TopBar/SearchButton": {"rect":[1354.0,8.0,70.0,40.0],"visible":true}, + "TopBar/ShortcutsButton": {"rect":[1626.0,8.0,83.0,40.0],"visible":true}, + "TopBar/StatisticsButton": {"rect":[1530.0,8.0,78.0,40.0],"visible":true}, + "TopBar/SyncButton": {"rect":[1442.0,8.0,70.0,40.0],"visible":true}, + "TopBar/Title": {"rect":[64.0,12.0,305.0,38.0],"visible":true}, + "TopBar/TopPanelContainer": {"rect":[0.0,0.0,1920.0,56.0],"visible":true} +}, +"players_added": { + ".": {"rect":[0.0,0.0,1920.0,1080.0],"visible":true}, + "AboutPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "AboutPopup/AboutPanel": {"rect":[720.0,153.0,600.0,640.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer": {"rect":[740.0,203.0,560.0,580.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox": {"rect":[740.0,203.0,1.0,545.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/DescriptionLabel": {"rect":[740.0,203.0,1.0,491.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/VersionLabel": {"rect":[740.0,203.0,1.0,23.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/WhatsNewLabel": {"rect":[740.0,203.0,1.0,23.0],"visible":false}, + "AboutPopup/AboutPanel/TitleLabel": {"rect":[720.0,153.0,600.0,50.0],"visible":false}, + "LogPopup": {"rect":[562.0,132.0,2620.0,1780.0],"visible":false}, + "LogPopup/LogPanel": {"rect":[562.0,132.0,700.0,700.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer": {"rect":[582.0,188.0,660.0,628.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer": {"rect":[582.0,188.0,0.0,0.0],"visible":false}, + "LogPopup/LogPanel/TitleLabel": {"rect":[562.0,140.0,700.0,40.0],"visible":false}, + "PopupBackdrop": {"rect":[0.0,0.0,1920.0,1080.0],"visible":false}, + "QRPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "QRPopup/QRPanel": {"rect":[720.0,153.0,450.0,520.0],"visible":false}, + "QRPopup/QRPanel/QRCodeRect": {"rect":[740.0,173.0,410.0,410.0],"visible":false}, + "QRPopup/QRPanel/URLLabel": {"rect":[740.0,603.0,410.0,40.0],"visible":false}, + "Quiz": {"rect":[0.0,0.0,1920.0,1080.0],"visible":true}, + "Quiz/CharacterSelectPopup": {"rect":[405.0,165.0,1152.0,640.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel": {"rect":[405.0,165.0,1290.0,665.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer": {"rect":[405.0,165.0,1290.0,659.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer": {"rect":[405.0,165.0,1190.0,620.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer": {"rect":[405.0,165.0,1150.0,600.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer": {"rect":[405.0,165.0,1150.0,600.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/TitleLabel": {"rect":[405.0,165.0,197.0,35.0],"visible":false}, + "Quiz/DebugLabel": {"rect":[974.0,904.0,120.0,69.0],"visible":true}, + "Quiz/FileDialog": {"rect":[0.0,0.0,616.0,400.0],"visible":false}, + "Quiz/InspirationList": {"rect":[1216.0,128.0,641.0,384.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer": {"rect":[1216.0,128.0,641.0,384.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#0": {"rect":[1216.0,128.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#0/#0": {"rect":[1224.0,130.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#1": {"rect":[1216.0,164.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#1/#0": {"rect":[1224.0,166.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#2": {"rect":[1216.0,200.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#2/#0": {"rect":[1224.0,202.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#3": {"rect":[1216.0,236.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#3/#0": {"rect":[1224.0,238.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#4": {"rect":[1216.0,272.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#4/#0": {"rect":[1224.0,274.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#5": {"rect":[1216.0,308.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#5/#0": {"rect":[1224.0,310.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#6": {"rect":[1216.0,344.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#6/#0": {"rect":[1224.0,346.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#7": {"rect":[1216.0,380.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#7/#0": {"rect":[1224.0,382.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#8": {"rect":[1216.0,416.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#8/#0": {"rect":[1224.0,418.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#9": {"rect":[1216.0,452.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#9/#0": {"rect":[1224.0,454.0,625.0,28.0],"visible":true}, + "Quiz/MusicListPanel": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow": {"rect":[64.0,384.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer": {"rect":[64.0,384.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/Panel": {"rect":[1068.0,384.0,80.0,0.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListLabel": {"rect":[64.0,384.0,1000.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListPlayers": {"rect":[1152.0,384.0,0.0,0.0],"visible":true}, + "Quiz/MusicPlayer": {"rect":[64.0,992.0,1792.0,71.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar": {"rect":[65.0,992.0,1791.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer": {"rect":[65.0,992.0,1791.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/MusicPlayerSlider": {"rect":[137.0,1000.0,1532.0,16.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/MusicTimeLabel": {"rect":[1673.0,996.0,79.0,23.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/PlayTextureButton": {"rect":[65.0,992.0,32.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/RestartTextureButton": {"rect":[101.0,992.0,32.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/VolumeSlider": {"rect":[1756.0,1000.0,100.0,16.0],"visible":true}, + "Quiz/MusicPlayer/RepeatSongCheckButton": {"rect":[64.0,1032.0,144.0,31.0],"visible":true}, + "Quiz/NowPlaying": {"rect":[64.0,64.0,1088.0,320.0],"visible":true}, + "Quiz/NowPlaying/SongLabel": {"rect":[64.0,137.0,1088.0,49.0],"visible":true}, + "Quiz/NowPlaying/SoundtrackLabel": {"rect":[64.0,64.0,1088.0,69.0],"visible":true}, + "Quiz/Open": {"rect":[1296.0,66.0,50.0,31.0],"visible":false}, + "Quiz/PlayersPanel": {"rect":[1216.0,576.0,641.0,401.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer": {"rect":[1216.0,576.0,641.0,401.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer": {"rect":[1216.0,620.0,641.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/AddPlayerButton": {"rect":[1617.0,620.0,39.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/Panel": {"rect":[1216.0,620.0,196.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/Panel2": {"rect":[1660.0,620.0,197.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/PlayerNameField": {"rect":[1416.0,620.0,197.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer": {"rect":[1216.0,576.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer/AddPlayersButton": {"rect":[1821.0,576.0,36.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer/Label": {"rect":[1216.0,576.0,601.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer": {"rect":[1216.0,620.0,641.0,357.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer": {"rect":[1216.0,620.0,641.0,357.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1": {"rect":[1216.0,664.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer": {"rect":[1216.0,664.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/AddPoint": {"rect":[1732.0,664.0,40.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Character": {"rect":[1614.0,664.0,80.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Name": {"rect":[1225.0,664.0,365.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel": {"rect":[1827.0,664.0,30.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel2": {"rect":[1698.0,664.0,30.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel3": {"rect":[1594.0,664.0,16.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel4": {"rect":[1216.0,664.0,5.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel5": {"rect":[1776.0,664.0,3.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/RemovePlayer": {"rect":[1817.0,664.0,40.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/RemovePoint": {"rect":[1783.0,664.0,40.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow": {"rect":[1216.0,620.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer": {"rect":[1216.0,620.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/AddPoint": {"rect":[1732.0,620.0,40.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Character": {"rect":[1614.0,620.0,80.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Name": {"rect":[1225.0,620.0,365.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel": {"rect":[1827.0,620.0,30.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel2": {"rect":[1698.0,620.0,30.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel3": {"rect":[1594.0,620.0,16.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel4": {"rect":[1216.0,620.0,5.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel5": {"rect":[1776.0,620.0,3.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/RemovePlayer": {"rect":[1817.0,620.0,40.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/RemovePoint": {"rect":[1783.0,620.0,40.0,40.0],"visible":true}, + "Quiz/PopupBackdrop": {"rect":[0.0,0.0,1920.0,1080.0],"visible":false}, + "Quiz/QuizToolbar": {"rect":[1360.0,64.0,495.0,31.0],"visible":true}, + "Quiz/QuizToolbar/ResetPlaylistButton": {"rect":[1360.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/ResetPointsButton": {"rect":[1488.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/SettingsButton": {"rect":[1616.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/SoundTestButton": {"rect":[1744.0,64.0,111.0,31.0],"visible":true}, + "Quiz/SettingsPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel": {"rect":[720.0,153.0,500.0,640.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer": {"rect":[740.0,161.0,460.0,624.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer": {"rect":[740.0,161.0,296.0,490.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/AddToDatabaseCheckButton": {"rect":[740.0,161.0,260.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer": {"rect":[740.0,161.0,280.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/CacheLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/CacheTitleLabel": {"rect":[740.0,161.0,160.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/IncreaseCacheButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/LowerCacheButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/FullscreenButton": {"rect":[740.0,161.0,127.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer": {"rect":[740.0,161.0,239.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/IncreaseButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/Label2": {"rect":[740.0,161.0,119.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/LowerButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/ScoreLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideBeginningCheckButton": {"rect":[740.0,161.0,233.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer": {"rect":[740.0,161.0,274.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/HideForSecondsLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/HideForSecondsTitle": {"rect":[740.0,161.0,154.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/IncreaseHideForSecondsButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/LowerHideForSecondsButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideLengthCheckButton": {"rect":[740.0,161.0,206.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideNextTrackCheckButton": {"rect":[740.0,161.0,167.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideTicksCheckButton": {"rect":[740.0,161.0,205.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer": {"rect":[740.0,161.0,296.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/IncreaseInspirationSpeedButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/InspirationListSpeedLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/InspirationListSpeedTitle": {"rect":[740.0,161.0,176.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/LowerInspirationSpeedButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/Label": {"rect":[740.0,161.0,97.0,35.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/LocalButton": {"rect":[740.0,161.0,89.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/LowPlayedCheckButton": {"rect":[740.0,161.0,213.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/QuickSyncButton": {"rect":[740.0,161.0,131.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/SelectServerButton": {"rect":[740.0,161.0,267.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/StopAfterCurrentCheckButton": {"rect":[740.0,161.0,187.0,31.0],"visible":false}, + "Quiz/SongControls": {"rect":[65.0,944.0,303.0,31.0],"visible":true}, + "Quiz/SongControls/NextButton": {"rect":[193.0,944.0,175.0,31.0],"visible":true}, + "Quiz/SongControls/ShowAnswerButton": {"rect":[65.0,944.0,111.0,31.0],"visible":true}, + "Quiz/WinnerPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel": {"rect":[720.0,153.0,500.0,600.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer": {"rect":[720.0,178.0,500.0,257.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/PlayersList": {"rect":[720.0,178.0,0.0,0.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/WinnerLabel": {"rect":[720.0,178.0,132.0,49.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/WinnerPicture": {"rect":[720.0,178.0,480.0,200.0],"visible":false}, + "SearchPopup": {"rect":[56.0,96.0,1920.0,1080.0],"visible":false}, + "SearchPopup/ClearButton": {"rect":[992.0,112.0,182.0,80.0],"visible":false}, + "SearchPopup/ScrollContainer": {"rect":[56.0,224.0,1152.0,512.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults": {"rect":[56.0,224.0,0.0,0.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#0": {"rect":[56.0,224.0,2.0,98.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#1": {"rect":[56.0,224.0,2.0,150.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#2": {"rect":[56.0,224.0,2.0,150.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#3": {"rect":[56.0,224.0,2.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#4": {"rect":[56.0,224.0,2.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#5": {"rect":[56.0,224.0,2.0,98.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#6": {"rect":[56.0,224.0,2.0,462.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#7": {"rect":[56.0,224.0,2.0,202.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#8": {"rect":[56.0,224.0,2.0,98.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#9": {"rect":[56.0,224.0,2.0,150.0],"visible":false}, + "SearchPopup/SearchPanel": {"rect":[56.0,96.0,1152.0,640.0],"visible":false}, + "SearchPopup/Searchbar": {"rect":[88.0,112.0,872.0,80.0],"visible":false}, + "ShortcutsPopup": {"rect":[562.0,160.0,2620.0,1780.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#0": {"rect":[562.0,160.0,93.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0/#0": {"rect":[562.0,160.0,1.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0/#1": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0/#1": {"rect":[562.0,160.0,1.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0/#1": {"rect":[562.0,160.0,1.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0/#1": {"rect":[562.0,160.0,1.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0/#1": {"rect":[562.0,160.0,1.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4": {"rect":[562.0,160.0,6.0,214.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0": {"rect":[562.0,160.0,6.0,214.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0/#0": {"rect":[562.0,160.0,1.0,214.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0/#1": {"rect":[562.0,160.0,1.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0/#0": {"rect":[562.0,160.0,1.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0/#1": {"rect":[562.0,160.0,1.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0/#1": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "StatisticsPopupPanel": {"rect":[0.0,0.0,450.0,100.0],"visible":false}, + "StatisticsPopupPanel/Label": {"rect":[12.0,12.0,426.0,76.0],"visible":false}, + "StatisticsPopupPanel/StatisticsLabel": {"rect":[12.0,12.0,426.0,76.0],"visible":false}, + "SyncPopup": {"rect":[210.0,153.0,1920.0,1080.0],"visible":false}, + "SyncPopup/SyncPanel": {"rect":[210.0,153.0,1500.0,640.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer": {"rect":[230.0,161.0,1460.0,624.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer": {"rect":[230.0,161.0,84.0,70.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox/CaughtErrorsLabel": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/NoChangesLabel": {"rect":[230.0,161.0,319.0,35.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer": {"rect":[230.0,161.0,1300.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer/SyncProgressBar": {"rect":[230.0,161.0,1300.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/RestartSyncButton": {"rect":[230.0,161.0,200.0,80.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer": {"rect":[230.0,161.0,1071.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox/SoundtracksAddedLabel": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox/SoundtracksChangedContentLabel": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox/SoundtracksChangedTitleLabel": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox/SoundtracksReAddedLabel": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox/SoundtracksRemovedLabel": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTime": {"rect":[230.0,161.0,84.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTitleLabel": {"rect":[230.0,161.0,55.0,35.0],"visible":false}, + "TopBar": {"rect":[0.0,0.0,1920.0,56.0],"visible":true}, + "TopBar/AboutButton": {"rect":[1722.0,8.0,70.0,40.0],"visible":true}, + "TopBar/QRButton": {"rect":[1802.0,8.0,70.0,40.0],"visible":true}, + "TopBar/SearchButton": {"rect":[1354.0,8.0,70.0,40.0],"visible":true}, + "TopBar/ShortcutsButton": {"rect":[1626.0,8.0,83.0,40.0],"visible":true}, + "TopBar/StatisticsButton": {"rect":[1530.0,8.0,78.0,40.0],"visible":true}, + "TopBar/SyncButton": {"rect":[1442.0,8.0,70.0,40.0],"visible":true}, + "TopBar/Title": {"rect":[64.0,12.0,305.0,38.0],"visible":true}, + "TopBar/TopPanelContainer": {"rect":[0.0,0.0,1920.0,56.0],"visible":true} +}, +"playing": { + ".": {"rect":[0.0,0.0,1920.0,1080.0],"visible":true}, + "AboutPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "AboutPopup/AboutPanel": {"rect":[720.0,153.0,600.0,640.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer": {"rect":[740.0,203.0,560.0,580.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox": {"rect":[740.0,203.0,1.0,545.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/DescriptionLabel": {"rect":[740.0,203.0,1.0,491.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/VersionLabel": {"rect":[740.0,203.0,1.0,23.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/WhatsNewLabel": {"rect":[740.0,203.0,1.0,23.0],"visible":false}, + "AboutPopup/AboutPanel/TitleLabel": {"rect":[720.0,153.0,600.0,50.0],"visible":false}, + "LogPopup": {"rect":[562.0,132.0,2620.0,1780.0],"visible":false}, + "LogPopup/LogPanel": {"rect":[562.0,132.0,700.0,700.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer": {"rect":[582.0,188.0,660.0,628.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer": {"rect":[582.0,188.0,0.0,0.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer/#0": {"rect":[582.0,188.0,1.0,152.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer/#1": {"rect":[582.0,188.0,1.0,152.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer/#2": {"rect":[582.0,188.0,1.0,152.0],"visible":false}, + "LogPopup/LogPanel/TitleLabel": {"rect":[562.0,140.0,700.0,40.0],"visible":false}, + "PopupBackdrop": {"rect":[0.0,0.0,1920.0,1080.0],"visible":false}, + "QRPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "QRPopup/QRPanel": {"rect":[720.0,153.0,450.0,520.0],"visible":false}, + "QRPopup/QRPanel/QRCodeRect": {"rect":[740.0,173.0,410.0,410.0],"visible":false}, + "QRPopup/QRPanel/URLLabel": {"rect":[740.0,603.0,410.0,40.0],"visible":false}, + "Quiz": {"rect":[0.0,0.0,1920.0,1080.0],"visible":true}, + "Quiz/CharacterSelectPopup": {"rect":[405.0,165.0,1152.0,640.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel": {"rect":[405.0,165.0,1290.0,665.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer": {"rect":[405.0,165.0,1290.0,659.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer": {"rect":[405.0,165.0,1190.0,620.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer": {"rect":[405.0,165.0,1150.0,600.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer": {"rect":[405.0,165.0,1150.0,600.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/TitleLabel": {"rect":[405.0,165.0,197.0,35.0],"visible":false}, + "Quiz/DebugLabel": {"rect":[974.0,904.0,120.0,69.0],"visible":true}, + "Quiz/FileDialog": {"rect":[0.0,0.0,616.0,400.0],"visible":false}, + "Quiz/InspirationList": {"rect":[1216.0,128.0,641.0,384.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer": {"rect":[1216.0,128.0,641.0,384.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#0": {"rect":[1216.0,128.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#0/#0": {"rect":[1224.0,130.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#1": {"rect":[1216.0,164.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#1/#0": {"rect":[1224.0,166.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#2": {"rect":[1216.0,200.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#2/#0": {"rect":[1224.0,202.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#3": {"rect":[1216.0,236.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#3/#0": {"rect":[1224.0,238.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#4": {"rect":[1216.0,272.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#4/#0": {"rect":[1224.0,274.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#5": {"rect":[1216.0,308.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#5/#0": {"rect":[1224.0,310.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#6": {"rect":[1216.0,344.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#6/#0": {"rect":[1224.0,346.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#7": {"rect":[1216.0,380.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#7/#0": {"rect":[1224.0,382.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#8": {"rect":[1216.0,416.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#8/#0": {"rect":[1224.0,418.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#9": {"rect":[1216.0,452.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#9/#0": {"rect":[1224.0,454.0,625.0,28.0],"visible":true}, + "Quiz/MusicListPanel": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/#1": {"rect":[64.0,422.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/#1/HBoxContainer": {"rect":[64.0,422.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/#1/HBoxContainer/Panel": {"rect":[1068.0,422.0,80.0,0.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/#1/HBoxContainer/SongListLabel": {"rect":[64.0,422.0,1000.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/#1/HBoxContainer/SongListPlayers": {"rect":[1152.0,422.0,0.0,0.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow": {"rect":[64.0,384.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer": {"rect":[64.0,384.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/Panel": {"rect":[968.0,384.0,75.0,0.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListLabel": {"rect":[64.0,384.0,900.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListPlayers": {"rect":[1047.0,384.0,105.0,25.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListPlayers/#0": {"rect":[1047.0,384.0,51.0,25.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListPlayers/#1": {"rect":[1102.0,384.0,50.0,25.0],"visible":true}, + "Quiz/MusicPlayer": {"rect":[64.0,992.0,1792.0,71.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar": {"rect":[65.0,992.0,1791.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer": {"rect":[65.0,992.0,1791.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/MusicPlayerSlider": {"rect":[137.0,1000.0,1514.0,16.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/MusicTimeLabel": {"rect":[1655.0,996.0,97.0,23.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/PlayTextureButton": {"rect":[65.0,992.0,32.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/RestartTextureButton": {"rect":[101.0,992.0,32.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/VolumeSlider": {"rect":[1756.0,1000.0,100.0,16.0],"visible":true}, + "Quiz/MusicPlayer/RepeatSongCheckButton": {"rect":[64.0,1032.0,144.0,31.0],"visible":true}, + "Quiz/NowPlaying": {"rect":[64.0,64.0,1088.0,320.0],"visible":true}, + "Quiz/NowPlaying/SongLabel": {"rect":[64.0,137.0,1088.0,49.0],"visible":true}, + "Quiz/NowPlaying/SoundtrackLabel": {"rect":[64.0,64.0,1088.0,69.0],"visible":true}, + "Quiz/Open": {"rect":[1296.0,66.0,50.0,31.0],"visible":false}, + "Quiz/PlayersPanel": {"rect":[1216.0,576.0,641.0,401.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer": {"rect":[1216.0,576.0,641.0,401.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer": {"rect":[1216.0,620.0,641.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/AddPlayerButton": {"rect":[1617.0,620.0,39.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/Panel": {"rect":[1216.0,620.0,196.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/Panel2": {"rect":[1660.0,620.0,197.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/PlayerNameField": {"rect":[1416.0,620.0,197.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer": {"rect":[1216.0,576.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer/AddPlayersButton": {"rect":[1821.0,576.0,36.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer/Label": {"rect":[1216.0,576.0,601.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer": {"rect":[1216.0,620.0,641.0,357.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer": {"rect":[1216.0,620.0,641.0,357.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1": {"rect":[1216.0,664.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer": {"rect":[1216.0,664.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/AddPoint": {"rect":[1732.0,664.0,40.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Character": {"rect":[1614.0,664.0,80.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Name": {"rect":[1225.0,664.0,365.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel": {"rect":[1827.0,664.0,30.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel2": {"rect":[1698.0,664.0,30.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel3": {"rect":[1594.0,664.0,16.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel4": {"rect":[1216.0,664.0,5.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel5": {"rect":[1776.0,664.0,3.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/RemovePlayer": {"rect":[1817.0,664.0,40.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/RemovePoint": {"rect":[1783.0,664.0,40.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow": {"rect":[1216.0,620.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer": {"rect":[1216.0,620.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/AddPoint": {"rect":[1732.0,620.0,40.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Character": {"rect":[1614.0,620.0,80.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Name": {"rect":[1225.0,620.0,365.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel": {"rect":[1827.0,620.0,30.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel2": {"rect":[1698.0,620.0,30.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel3": {"rect":[1594.0,620.0,16.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel4": {"rect":[1216.0,620.0,5.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel5": {"rect":[1776.0,620.0,3.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/RemovePlayer": {"rect":[1817.0,620.0,40.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/RemovePoint": {"rect":[1783.0,620.0,40.0,40.0],"visible":true}, + "Quiz/PopupBackdrop": {"rect":[0.0,0.0,1920.0,1080.0],"visible":false}, + "Quiz/QuizToolbar": {"rect":[1360.0,64.0,495.0,31.0],"visible":true}, + "Quiz/QuizToolbar/ResetPlaylistButton": {"rect":[1360.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/ResetPointsButton": {"rect":[1488.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/SettingsButton": {"rect":[1616.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/SoundTestButton": {"rect":[1744.0,64.0,111.0,31.0],"visible":true}, + "Quiz/SettingsPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel": {"rect":[720.0,153.0,500.0,640.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer": {"rect":[740.0,161.0,460.0,624.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer": {"rect":[740.0,161.0,296.0,490.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/AddToDatabaseCheckButton": {"rect":[740.0,161.0,260.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer": {"rect":[740.0,161.0,280.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/CacheLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/CacheTitleLabel": {"rect":[740.0,161.0,160.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/IncreaseCacheButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/LowerCacheButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/FullscreenButton": {"rect":[740.0,161.0,127.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer": {"rect":[740.0,161.0,239.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/IncreaseButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/Label2": {"rect":[740.0,161.0,119.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/LowerButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/ScoreLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideBeginningCheckButton": {"rect":[740.0,161.0,233.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer": {"rect":[740.0,161.0,274.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/HideForSecondsLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/HideForSecondsTitle": {"rect":[740.0,161.0,154.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/IncreaseHideForSecondsButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/LowerHideForSecondsButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideLengthCheckButton": {"rect":[740.0,161.0,206.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideNextTrackCheckButton": {"rect":[740.0,161.0,167.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideTicksCheckButton": {"rect":[740.0,161.0,205.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer": {"rect":[740.0,161.0,296.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/IncreaseInspirationSpeedButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/InspirationListSpeedLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/InspirationListSpeedTitle": {"rect":[740.0,161.0,176.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/LowerInspirationSpeedButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/Label": {"rect":[740.0,161.0,97.0,35.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/LocalButton": {"rect":[740.0,161.0,89.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/LowPlayedCheckButton": {"rect":[740.0,161.0,213.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/QuickSyncButton": {"rect":[740.0,161.0,131.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/SelectServerButton": {"rect":[740.0,161.0,267.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/StopAfterCurrentCheckButton": {"rect":[740.0,161.0,187.0,31.0],"visible":false}, + "Quiz/SongControls": {"rect":[65.0,944.0,303.0,31.0],"visible":true}, + "Quiz/SongControls/NextButton": {"rect":[193.0,944.0,175.0,31.0],"visible":true}, + "Quiz/SongControls/ShowAnswerButton": {"rect":[65.0,944.0,111.0,31.0],"visible":true}, + "Quiz/WinnerPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel": {"rect":[720.0,153.0,500.0,600.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer": {"rect":[720.0,178.0,500.0,257.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/PlayersList": {"rect":[720.0,178.0,0.0,0.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/WinnerLabel": {"rect":[720.0,178.0,132.0,49.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/WinnerPicture": {"rect":[720.0,178.0,480.0,200.0],"visible":false}, + "SearchPopup": {"rect":[56.0,96.0,1920.0,1080.0],"visible":false}, + "SearchPopup/ClearButton": {"rect":[992.0,112.0,182.0,80.0],"visible":false}, + "SearchPopup/ScrollContainer": {"rect":[56.0,224.0,1152.0,512.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults": {"rect":[56.0,224.0,0.0,0.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#0": {"rect":[56.0,224.0,2.0,98.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#1": {"rect":[56.0,224.0,2.0,150.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#2": {"rect":[56.0,224.0,2.0,150.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#3": {"rect":[56.0,224.0,2.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#4": {"rect":[56.0,224.0,2.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#5": {"rect":[56.0,224.0,2.0,98.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#6": {"rect":[56.0,224.0,2.0,462.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#7": {"rect":[56.0,224.0,2.0,202.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#8": {"rect":[56.0,224.0,2.0,98.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#9": {"rect":[56.0,224.0,2.0,150.0],"visible":false}, + "SearchPopup/SearchPanel": {"rect":[56.0,96.0,1152.0,640.0],"visible":false}, + "SearchPopup/Searchbar": {"rect":[88.0,112.0,872.0,80.0],"visible":false}, + "ShortcutsPopup": {"rect":[562.0,160.0,2620.0,1780.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#0": {"rect":[562.0,160.0,93.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0/#0": {"rect":[562.0,160.0,1.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0/#1": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0/#1": {"rect":[562.0,160.0,1.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0/#1": {"rect":[562.0,160.0,1.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0/#1": {"rect":[562.0,160.0,1.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0/#1": {"rect":[562.0,160.0,1.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4": {"rect":[562.0,160.0,6.0,214.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0": {"rect":[562.0,160.0,6.0,214.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0/#0": {"rect":[562.0,160.0,1.0,214.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0/#1": {"rect":[562.0,160.0,1.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0/#0": {"rect":[562.0,160.0,1.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0/#1": {"rect":[562.0,160.0,1.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0/#1": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "StatisticsPopupPanel": {"rect":[0.0,0.0,450.0,100.0],"visible":false}, + "StatisticsPopupPanel/Label": {"rect":[12.0,12.0,426.0,76.0],"visible":false}, + "StatisticsPopupPanel/StatisticsLabel": {"rect":[12.0,12.0,426.0,76.0],"visible":false}, + "SyncPopup": {"rect":[210.0,153.0,1920.0,1080.0],"visible":false}, + "SyncPopup/SyncPanel": {"rect":[210.0,153.0,1500.0,640.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer": {"rect":[230.0,161.0,1460.0,624.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer": {"rect":[230.0,161.0,84.0,70.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox/CaughtErrorsLabel": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/NoChangesLabel": {"rect":[230.0,161.0,319.0,35.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer": {"rect":[230.0,161.0,1300.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer/SyncProgressBar": {"rect":[230.0,161.0,1300.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/RestartSyncButton": {"rect":[230.0,161.0,200.0,80.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer": {"rect":[230.0,161.0,1071.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox/SoundtracksAddedLabel": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox/SoundtracksChangedContentLabel": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox/SoundtracksChangedTitleLabel": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox/SoundtracksReAddedLabel": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox/SoundtracksRemovedLabel": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTime": {"rect":[230.0,161.0,84.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTitleLabel": {"rect":[230.0,161.0,55.0,35.0],"visible":false}, + "TopBar": {"rect":[0.0,0.0,1920.0,56.0],"visible":true}, + "TopBar/AboutButton": {"rect":[1722.0,8.0,70.0,40.0],"visible":true}, + "TopBar/QRButton": {"rect":[1802.0,8.0,70.0,40.0],"visible":true}, + "TopBar/SearchButton": {"rect":[1354.0,8.0,70.0,40.0],"visible":true}, + "TopBar/ShortcutsButton": {"rect":[1626.0,8.0,83.0,40.0],"visible":true}, + "TopBar/StatisticsButton": {"rect":[1530.0,8.0,78.0,40.0],"visible":true}, + "TopBar/SyncButton": {"rect":[1442.0,8.0,70.0,40.0],"visible":true}, + "TopBar/Title": {"rect":[64.0,12.0,305.0,38.0],"visible":true}, + "TopBar/TopPanelContainer": {"rect":[0.0,0.0,1920.0,56.0],"visible":true} +}, +"popup_AboutWindow": { + "AboutPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":true}, + "AboutPopup/AboutPanel": {"rect":[720.0,153.0,600.0,640.0],"visible":true}, + "AboutPopup/AboutPanel/ScrollContainer": {"rect":[740.0,203.0,560.0,580.0],"visible":true}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox": {"rect":[740.0,203.0,552.0,2105.0],"visible":true}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/DescriptionLabel": {"rect":[740.0,230.0,552.0,49.0],"visible":true}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/VersionLabel": {"rect":[740.0,203.0,552.0,23.0],"visible":true}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/WhatsNewLabel": {"rect":[740.0,283.0,552.0,2025.0],"visible":true}, + "AboutPopup/AboutPanel/TitleLabel": {"rect":[720.0,153.0,600.0,50.0],"visible":true} +}, +"popup_Log": { + "LogPopup": {"rect":[562.0,132.0,2620.0,1780.0],"visible":true}, + "LogPopup/LogPanel": {"rect":[562.0,132.0,700.0,700.0],"visible":true}, + "LogPopup/LogPanel/LogScrollContainer": {"rect":[582.0,188.0,660.0,628.0],"visible":true}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer": {"rect":[582.0,188.0,660.0,86.0],"visible":true}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer/#0": {"rect":[582.0,188.0,660.0,28.0],"visible":true}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer/#1": {"rect":[582.0,217.0,660.0,28.0],"visible":true}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer/#2": {"rect":[582.0,246.0,660.0,28.0],"visible":true}, + "LogPopup/LogPanel/TitleLabel": {"rect":[562.0,140.0,700.0,40.0],"visible":true} +}, +"popup_NewCharacterSelect": { + "Quiz/CharacterSelectPopup": {"rect":[405.0,165.0,1152.0,640.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel": {"rect":[405.0,165.0,1290.0,665.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer": {"rect":[405.0,165.0,1290.0,659.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer": {"rect":[405.0,204.0,1290.0,620.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer": {"rect":[425.0,214.0,1250.0,600.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer": {"rect":[425.0,214.0,1215.0,600.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1": {"rect":[670.0,214.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel": {"rect":[670.0,214.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer": {"rect":[670.0,214.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer": {"rect":[685.0,224.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[685.0,328.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[690.0,224.0,200.0,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10": {"rect":[425.0,524.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel": {"rect":[425.0,524.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer": {"rect":[425.0,524.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer": {"rect":[440.0,534.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[440.0,638.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[445.0,534.0,200.0,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11": {"rect":[670.0,524.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel": {"rect":[670.0,524.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer": {"rect":[670.0,524.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer": {"rect":[685.0,534.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[685.0,638.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[690.0,534.0,200.0,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12": {"rect":[915.0,524.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel": {"rect":[915.0,524.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer": {"rect":[915.0,524.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer": {"rect":[930.0,534.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[930.0,638.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[935.0,534.0,200.0,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2": {"rect":[915.0,214.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel": {"rect":[915.0,214.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer": {"rect":[915.0,214.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer": {"rect":[930.0,224.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[930.0,328.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[935.0,224.0,200.0,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3": {"rect":[1160.0,214.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel": {"rect":[1160.0,214.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer": {"rect":[1160.0,214.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer": {"rect":[1175.0,224.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[1175.0,328.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[1180.0,224.0,200.0,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4": {"rect":[1405.0,214.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel": {"rect":[1405.0,214.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer": {"rect":[1405.0,214.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer": {"rect":[1420.0,224.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[1420.0,328.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[1425.0,224.0,200.0,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5": {"rect":[425.0,369.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel": {"rect":[425.0,369.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer": {"rect":[425.0,369.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer": {"rect":[440.0,379.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[440.0,483.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[445.0,379.0,200.0,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6": {"rect":[670.0,369.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel": {"rect":[670.0,369.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer": {"rect":[670.0,369.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer": {"rect":[685.0,379.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[685.0,483.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[690.0,379.0,200.0,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7": {"rect":[915.0,369.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel": {"rect":[915.0,369.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer": {"rect":[915.0,369.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer": {"rect":[930.0,379.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[930.0,483.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[935.0,379.0,200.0,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8": {"rect":[1160.0,369.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel": {"rect":[1160.0,369.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer": {"rect":[1160.0,369.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer": {"rect":[1175.0,379.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[1175.0,483.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[1180.0,379.0,200.0,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9": {"rect":[1405.0,369.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel": {"rect":[1405.0,369.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer": {"rect":[1405.0,369.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer": {"rect":[1420.0,379.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[1420.0,483.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[1425.0,379.0,200.0,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard": {"rect":[425.0,214.0,235.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel": {"rect":[425.0,214.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer": {"rect":[425.0,214.0,240.0,145.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer": {"rect":[440.0,224.0,210.0,135.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[440.0,328.0,210.0,23.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[444.0,224.0,200.66,100.0],"visible":true}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/TitleLabel": {"rect":[405.0,165.0,1290.0,35.0],"visible":true} +}, +"popup_QRWindow": { + "QRPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":true}, + "QRPopup/QRPanel": {"rect":[720.0,153.0,450.0,520.0],"visible":true}, + "QRPopup/QRPanel/QRCodeRect": {"rect":[740.0,173.0,410.0,410.0],"visible":true}, + "QRPopup/QRPanel/URLLabel": {"rect":[740.0,603.0,410.0,40.0],"visible":true} +}, +"popup_Search": { + "SearchPopup": {"rect":[56.0,96.0,1920.0,1080.0],"visible":true}, + "SearchPopup/ClearButton": {"rect":[992.0,112.0,182.0,80.0],"visible":true}, + "SearchPopup/ScrollContainer": {"rect":[56.0,224.0,1152.0,512.0],"visible":true}, + "SearchPopup/ScrollContainer/SearchResults": {"rect":[56.0,224.0,1136.0,532.0],"visible":true}, + "SearchPopup/ScrollContainer/SearchResults/#0": {"rect":[56.0,224.0,1136.0,46.0],"visible":true}, + "SearchPopup/ScrollContainer/SearchResults/#1": {"rect":[56.0,278.0,1136.0,46.0],"visible":true}, + "SearchPopup/ScrollContainer/SearchResults/#2": {"rect":[56.0,332.0,1136.0,46.0],"visible":true}, + "SearchPopup/ScrollContainer/SearchResults/#3": {"rect":[56.0,386.0,1136.0,46.0],"visible":true}, + "SearchPopup/ScrollContainer/SearchResults/#4": {"rect":[56.0,440.0,1136.0,46.0],"visible":true}, + "SearchPopup/ScrollContainer/SearchResults/#5": {"rect":[56.0,494.0,1136.0,46.0],"visible":true}, + "SearchPopup/ScrollContainer/SearchResults/#6": {"rect":[56.0,548.0,1136.0,46.0],"visible":true}, + "SearchPopup/ScrollContainer/SearchResults/#7": {"rect":[56.0,602.0,1136.0,46.0],"visible":true}, + "SearchPopup/ScrollContainer/SearchResults/#8": {"rect":[56.0,656.0,1136.0,46.0],"visible":true}, + "SearchPopup/ScrollContainer/SearchResults/#9": {"rect":[56.0,710.0,1136.0,46.0],"visible":true}, + "SearchPopup/SearchPanel": {"rect":[56.0,96.0,1152.0,640.0],"visible":true}, + "SearchPopup/Searchbar": {"rect":[88.0,112.0,872.0,80.0],"visible":true} +}, +"popup_SettingsWindow": { + "Quiz/SettingsPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel": {"rect":[720.0,153.0,500.0,640.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer": {"rect":[740.0,161.0,460.0,624.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer": {"rect":[740.0,161.0,460.0,455.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/AddToDatabaseCheckButton": {"rect":[740.0,270.0,460.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer": {"rect":[740.0,480.0,460.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/CacheLabel": {"rect":[1084.0,484.0,48.0,23.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/CacheTitleLabel": {"rect":[740.0,484.0,340.0,23.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/IncreaseCacheButton": {"rect":[1170.0,480.0,30.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/LowerCacheButton": {"rect":[1136.0,480.0,30.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/FullscreenButton": {"rect":[740.0,585.0,460.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer": {"rect":[740.0,445.0,460.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/IncreaseButton": {"rect":[1170.0,445.0,30.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/Label2": {"rect":[740.0,449.0,340.0,23.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/LowerButton": {"rect":[1136.0,445.0,30.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/ScoreLabel": {"rect":[1084.0,449.0,48.0,23.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideBeginningCheckButton": {"rect":[740.0,340.0,460.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer": {"rect":[740.0,161.0,274.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/HideForSecondsLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/HideForSecondsTitle": {"rect":[740.0,161.0,154.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/IncreaseHideForSecondsButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/LowerHideForSecondsButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideLengthCheckButton": {"rect":[740.0,375.0,460.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideNextTrackCheckButton": {"rect":[740.0,235.0,460.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideTicksCheckButton": {"rect":[740.0,410.0,460.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer": {"rect":[740.0,515.0,460.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/IncreaseInspirationSpeedButton": {"rect":[1170.0,515.0,30.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/InspirationListSpeedLabel": {"rect":[1084.0,519.0,48.0,23.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/InspirationListSpeedTitle": {"rect":[740.0,519.0,340.0,23.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/LowerInspirationSpeedButton": {"rect":[1136.0,515.0,30.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/Label": {"rect":[740.0,161.0,460.0,35.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/LocalButton": {"rect":[740.0,161.0,89.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/LowPlayedCheckButton": {"rect":[740.0,305.0,460.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/QuickSyncButton": {"rect":[740.0,161.0,131.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/SelectServerButton": {"rect":[740.0,550.0,460.0,31.0],"visible":true}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/StopAfterCurrentCheckButton": {"rect":[740.0,200.0,460.0,31.0],"visible":true} +}, +"popup_ShortcutsWindow": { + "ShortcutsPopup": {"rect":[562.0,160.0,2620.0,1780.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel": {"rect":[562.0,160.0,700.0,700.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer": {"rect":[562.0,160.0,700.0,700.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox": {"rect":[562.0,160.0,700.0,700.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#0": {"rect":[562.0,160.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1": {"rect":[562.0,192.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0": {"rect":[562.0,192.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0/#0": {"rect":[562.0,192.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0/#1": {"rect":[914.0,192.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10": {"rect":[562.0,480.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0": {"rect":[562.0,480.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0/#0": {"rect":[562.0,480.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0/#1": {"rect":[914.0,480.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11": {"rect":[562.0,512.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0": {"rect":[562.0,512.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0/#0": {"rect":[562.0,512.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0/#1": {"rect":[914.0,512.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12": {"rect":[562.0,544.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0": {"rect":[562.0,544.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0/#0": {"rect":[562.0,544.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0/#1": {"rect":[914.0,544.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13": {"rect":[562.0,576.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0": {"rect":[562.0,576.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0/#0": {"rect":[562.0,576.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0/#1": {"rect":[914.0,576.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14": {"rect":[562.0,608.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0": {"rect":[562.0,608.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0/#0": {"rect":[562.0,608.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0/#1": {"rect":[914.0,608.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15": {"rect":[562.0,640.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0": {"rect":[562.0,640.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0/#0": {"rect":[562.0,640.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0/#1": {"rect":[914.0,640.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16": {"rect":[562.0,672.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0": {"rect":[562.0,672.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0/#0": {"rect":[562.0,672.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0/#1": {"rect":[914.0,672.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2": {"rect":[562.0,224.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0": {"rect":[562.0,224.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0/#0": {"rect":[562.0,224.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0/#1": {"rect":[914.0,224.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3": {"rect":[562.0,256.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0": {"rect":[562.0,256.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0/#0": {"rect":[562.0,256.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0/#1": {"rect":[914.0,256.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4": {"rect":[562.0,288.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0": {"rect":[562.0,288.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0/#0": {"rect":[562.0,288.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0/#1": {"rect":[914.0,288.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5": {"rect":[562.0,320.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0": {"rect":[562.0,320.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0/#0": {"rect":[562.0,320.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0/#1": {"rect":[914.0,320.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6": {"rect":[562.0,352.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0": {"rect":[562.0,352.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0/#0": {"rect":[562.0,352.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0/#1": {"rect":[914.0,352.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7": {"rect":[562.0,384.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0": {"rect":[562.0,384.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0/#0": {"rect":[562.0,384.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0/#1": {"rect":[914.0,384.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8": {"rect":[562.0,416.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0": {"rect":[562.0,416.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0/#0": {"rect":[562.0,416.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0/#1": {"rect":[914.0,416.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9": {"rect":[562.0,448.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0": {"rect":[562.0,448.0,700.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0/#0": {"rect":[562.0,448.0,348.0,28.0],"visible":true}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0/#1": {"rect":[914.0,448.0,348.0,28.0],"visible":true} +}, +"popup_StatisticsPopupPanel": { + "StatisticsPopupPanel": {"rect":[735.0,490.0,450.0,100.0],"visible":true}, + "StatisticsPopupPanel/Label": {"rect":[12.0,12.0,426.0,76.0],"visible":true}, + "StatisticsPopupPanel/StatisticsLabel": {"rect":[12.0,12.0,426.0,76.0],"visible":true} +}, +"popup_SyncWindow": { + "SyncPopup": {"rect":[210.0,153.0,1920.0,1080.0],"visible":true}, + "SyncPopup/SyncPanel": {"rect":[210.0,153.0,1500.0,640.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer": {"rect":[230.0,161.0,1460.0,624.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer": {"rect":[230.0,161.0,1460.0,70.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox/CaughtErrorsLabel": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/NoChangesLabel": {"rect":[230.0,161.0,319.0,35.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer": {"rect":[230.0,161.0,1300.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer/SyncProgressBar": {"rect":[230.0,161.0,1300.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/RestartSyncButton": {"rect":[230.0,161.0,200.0,80.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer": {"rect":[230.0,161.0,1071.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox/SoundtracksAddedLabel": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox/SoundtracksChangedContentLabel": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox/SoundtracksChangedTitleLabel": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox/SoundtracksReAddedLabel": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox/SoundtracksRemovedLabel": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTime": {"rect":[230.0,208.0,1460.0,23.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTitleLabel": {"rect":[230.0,161.0,1460.0,35.0],"visible":true} +}, +"popup_WinnerWindow": { + "Quiz/WinnerPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":true}, + "Quiz/WinnerPopup/WinnerPanel": {"rect":[720.0,153.0,500.0,600.0],"visible":true}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer": {"rect":[720.0,178.0,500.0,280.0],"visible":true}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/PlayersList": {"rect":[720.0,435.0,500.0,23.0],"visible":true}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/PlayersList/#0": {"rect":[720.0,435.0,500.0,23.0],"visible":true}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/WinnerLabel": {"rect":[720.0,382.0,500.0,49.0],"visible":true}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/WinnerPicture": {"rect":[720.0,178.0,500.0,200.0],"visible":true} +}, +"startup": { + ".": {"rect":[0.0,0.0,1920.0,1080.0],"visible":true}, + "AboutPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "AboutPopup/AboutPanel": {"rect":[720.0,153.0,600.0,640.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer": {"rect":[740.0,203.0,560.0,580.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox": {"rect":[740.0,203.0,1.0,545.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/DescriptionLabel": {"rect":[740.0,203.0,1.0,491.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/VersionLabel": {"rect":[740.0,203.0,1.0,23.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/WhatsNewLabel": {"rect":[740.0,203.0,1.0,23.0],"visible":false}, + "AboutPopup/AboutPanel/TitleLabel": {"rect":[720.0,153.0,600.0,50.0],"visible":false}, + "LogPopup": {"rect":[562.0,132.0,2620.0,1780.0],"visible":false}, + "LogPopup/LogPanel": {"rect":[562.0,132.0,700.0,700.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer": {"rect":[582.0,188.0,660.0,628.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer": {"rect":[582.0,188.0,0.0,0.0],"visible":false}, + "LogPopup/LogPanel/TitleLabel": {"rect":[562.0,140.0,700.0,40.0],"visible":false}, + "PopupBackdrop": {"rect":[0.0,0.0,1920.0,1080.0],"visible":false}, + "QRPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "QRPopup/QRPanel": {"rect":[720.0,153.0,450.0,520.0],"visible":false}, + "QRPopup/QRPanel/QRCodeRect": {"rect":[740.0,173.0,410.0,410.0],"visible":false}, + "QRPopup/QRPanel/URLLabel": {"rect":[740.0,603.0,410.0,40.0],"visible":false}, + "Quiz": {"rect":[0.0,0.0,1920.0,1080.0],"visible":true}, + "Quiz/CharacterSelectPopup": {"rect":[405.0,165.0,1152.0,640.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel": {"rect":[405.0,165.0,1290.0,665.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer": {"rect":[405.0,165.0,1290.0,659.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer": {"rect":[405.0,165.0,1190.0,620.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer": {"rect":[405.0,165.0,1150.0,600.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer": {"rect":[405.0,165.0,1150.0,600.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard": {"rect":[405.0,165.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer": {"rect":[405.0,165.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer": {"rect":[405.0,165.0,200.0,127.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[405.0,165.0,34.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[405.0,165.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/TitleLabel": {"rect":[405.0,165.0,197.0,35.0],"visible":false}, + "Quiz/DebugLabel": {"rect":[974.0,904.0,120.0,69.0],"visible":true}, + "Quiz/FileDialog": {"rect":[0.0,0.0,616.0,400.0],"visible":false}, + "Quiz/InspirationList": {"rect":[1216.0,128.0,641.0,384.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer": {"rect":[1216.0,128.0,641.0,384.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#0": {"rect":[1216.0,128.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#0/#0": {"rect":[1224.0,130.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#1": {"rect":[1216.0,164.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#1/#0": {"rect":[1224.0,166.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#2": {"rect":[1216.0,200.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#2/#0": {"rect":[1224.0,202.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#3": {"rect":[1216.0,236.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#3/#0": {"rect":[1224.0,238.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#4": {"rect":[1216.0,272.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#4/#0": {"rect":[1224.0,274.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#5": {"rect":[1216.0,308.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#5/#0": {"rect":[1224.0,310.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#6": {"rect":[1216.0,344.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#6/#0": {"rect":[1224.0,346.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#7": {"rect":[1216.0,380.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#7/#0": {"rect":[1224.0,382.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#8": {"rect":[1216.0,416.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#8/#0": {"rect":[1224.0,418.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#9": {"rect":[1216.0,452.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#9/#0": {"rect":[1224.0,454.0,625.0,28.0],"visible":true}, + "Quiz/MusicListPanel": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow": {"rect":[64.0,384.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer": {"rect":[64.0,384.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/Panel": {"rect":[1068.0,384.0,80.0,0.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListLabel": {"rect":[64.0,384.0,1000.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListPlayers": {"rect":[1152.0,384.0,0.0,0.0],"visible":true}, + "Quiz/MusicPlayer": {"rect":[64.0,992.0,1792.0,71.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar": {"rect":[65.0,992.0,1791.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer": {"rect":[65.0,992.0,1791.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/MusicPlayerSlider": {"rect":[137.0,1000.0,1532.0,16.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/MusicTimeLabel": {"rect":[1673.0,996.0,79.0,23.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/PlayTextureButton": {"rect":[65.0,992.0,32.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/RestartTextureButton": {"rect":[101.0,992.0,32.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/VolumeSlider": {"rect":[1756.0,1000.0,100.0,16.0],"visible":true}, + "Quiz/MusicPlayer/RepeatSongCheckButton": {"rect":[64.0,1032.0,144.0,31.0],"visible":true}, + "Quiz/NowPlaying": {"rect":[64.0,64.0,1088.0,320.0],"visible":true}, + "Quiz/NowPlaying/SongLabel": {"rect":[64.0,137.0,1088.0,49.0],"visible":true}, + "Quiz/NowPlaying/SoundtrackLabel": {"rect":[64.0,64.0,1088.0,69.0],"visible":true}, + "Quiz/Open": {"rect":[1296.0,66.0,50.0,31.0],"visible":false}, + "Quiz/PlayersPanel": {"rect":[1216.0,576.0,641.0,401.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer": {"rect":[1216.0,576.0,641.0,401.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer": {"rect":[1216.0,576.0,60.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/AddPlayerButton": {"rect":[1216.0,576.0,39.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/Panel": {"rect":[1216.0,576.0,0.0,0.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/Panel2": {"rect":[1216.0,576.0,0.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/PlayerNameField": {"rect":[1216.0,576.0,8.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer": {"rect":[1216.0,576.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer/AddPlayersButton": {"rect":[1821.0,576.0,36.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer/Label": {"rect":[1216.0,576.0,601.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer": {"rect":[1216.0,620.0,641.0,357.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer": {"rect":[1216.0,620.0,641.0,357.0],"visible":true}, + "Quiz/PopupBackdrop": {"rect":[0.0,0.0,1920.0,1080.0],"visible":false}, + "Quiz/QuizToolbar": {"rect":[1360.0,64.0,495.0,31.0],"visible":true}, + "Quiz/QuizToolbar/ResetPlaylistButton": {"rect":[1360.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/ResetPointsButton": {"rect":[1488.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/SettingsButton": {"rect":[1616.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/SoundTestButton": {"rect":[1744.0,64.0,111.0,31.0],"visible":true}, + "Quiz/SettingsPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel": {"rect":[720.0,153.0,500.0,640.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer": {"rect":[740.0,161.0,460.0,624.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer": {"rect":[740.0,161.0,296.0,490.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/AddToDatabaseCheckButton": {"rect":[740.0,161.0,260.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer": {"rect":[740.0,161.0,280.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/CacheLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/CacheTitleLabel": {"rect":[740.0,161.0,160.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/IncreaseCacheButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/LowerCacheButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/FullscreenButton": {"rect":[740.0,161.0,127.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer": {"rect":[740.0,161.0,239.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/IncreaseButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/Label2": {"rect":[740.0,161.0,119.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/LowerButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/ScoreLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideBeginningCheckButton": {"rect":[740.0,161.0,233.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer": {"rect":[740.0,161.0,274.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/HideForSecondsLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/HideForSecondsTitle": {"rect":[740.0,161.0,154.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/IncreaseHideForSecondsButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/LowerHideForSecondsButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideLengthCheckButton": {"rect":[740.0,161.0,206.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideNextTrackCheckButton": {"rect":[740.0,161.0,167.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideTicksCheckButton": {"rect":[740.0,161.0,205.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer": {"rect":[740.0,161.0,296.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/IncreaseInspirationSpeedButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/InspirationListSpeedLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/InspirationListSpeedTitle": {"rect":[740.0,161.0,176.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/LowerInspirationSpeedButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/Label": {"rect":[740.0,161.0,97.0,35.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/LocalButton": {"rect":[740.0,161.0,89.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/LowPlayedCheckButton": {"rect":[740.0,161.0,213.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/QuickSyncButton": {"rect":[740.0,161.0,131.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/SelectServerButton": {"rect":[740.0,161.0,267.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/StopAfterCurrentCheckButton": {"rect":[740.0,161.0,187.0,31.0],"visible":false}, + "Quiz/SongControls": {"rect":[65.0,944.0,303.0,31.0],"visible":true}, + "Quiz/SongControls/NextButton": {"rect":[193.0,944.0,175.0,31.0],"visible":true}, + "Quiz/SongControls/ShowAnswerButton": {"rect":[65.0,944.0,111.0,31.0],"visible":true}, + "Quiz/WinnerPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel": {"rect":[720.0,153.0,500.0,600.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer": {"rect":[720.0,178.0,500.0,257.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/PlayersList": {"rect":[720.0,178.0,0.0,0.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/WinnerLabel": {"rect":[720.0,178.0,132.0,49.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/WinnerPicture": {"rect":[720.0,178.0,480.0,200.0],"visible":false}, + "SearchPopup": {"rect":[56.0,96.0,1920.0,1080.0],"visible":false}, + "SearchPopup/ClearButton": {"rect":[992.0,112.0,182.0,80.0],"visible":false}, + "SearchPopup/ScrollContainer": {"rect":[56.0,224.0,1152.0,512.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults": {"rect":[56.0,224.0,0.0,0.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#0": {"rect":[56.0,224.0,2.0,98.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#1": {"rect":[56.0,224.0,2.0,150.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#2": {"rect":[56.0,224.0,2.0,150.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#3": {"rect":[56.0,224.0,2.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#4": {"rect":[56.0,224.0,2.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#5": {"rect":[56.0,224.0,2.0,98.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#6": {"rect":[56.0,224.0,2.0,462.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#7": {"rect":[56.0,224.0,2.0,202.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#8": {"rect":[56.0,224.0,2.0,98.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#9": {"rect":[56.0,224.0,2.0,150.0],"visible":false}, + "SearchPopup/SearchPanel": {"rect":[56.0,96.0,1152.0,640.0],"visible":false}, + "SearchPopup/Searchbar": {"rect":[88.0,112.0,872.0,80.0],"visible":false}, + "ShortcutsPopup": {"rect":[562.0,160.0,2620.0,1780.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#0": {"rect":[562.0,160.0,93.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0/#0": {"rect":[562.0,160.0,1.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0/#1": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0/#1": {"rect":[562.0,160.0,1.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0": {"rect":[562.0,160.0,6.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0/#1": {"rect":[562.0,160.0,1.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0/#1": {"rect":[562.0,160.0,1.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0/#1": {"rect":[562.0,160.0,1.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4": {"rect":[562.0,160.0,6.0,214.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0": {"rect":[562.0,160.0,6.0,214.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0/#0": {"rect":[562.0,160.0,1.0,214.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0/#1": {"rect":[562.0,160.0,1.0,121.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0": {"rect":[562.0,160.0,6.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0/#0": {"rect":[562.0,160.0,1.0,152.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0/#1": {"rect":[562.0,160.0,1.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0/#1": {"rect":[562.0,160.0,1.0,59.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0": {"rect":[562.0,160.0,6.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0/#0": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0/#1": {"rect":[562.0,160.0,1.0,90.0],"visible":false}, + "StatisticsPopupPanel": {"rect":[0.0,0.0,450.0,100.0],"visible":false}, + "StatisticsPopupPanel/Label": {"rect":[12.0,12.0,426.0,76.0],"visible":false}, + "StatisticsPopupPanel/StatisticsLabel": {"rect":[12.0,12.0,426.0,76.0],"visible":false}, + "SyncPopup": {"rect":[210.0,153.0,1920.0,1080.0],"visible":false}, + "SyncPopup/SyncPanel": {"rect":[210.0,153.0,1500.0,640.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer": {"rect":[230.0,161.0,1460.0,624.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer": {"rect":[230.0,161.0,84.0,70.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox/CaughtErrorsLabel": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/NoChangesLabel": {"rect":[230.0,161.0,319.0,35.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer": {"rect":[230.0,161.0,1300.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer/SyncProgressBar": {"rect":[230.0,161.0,1300.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/RestartSyncButton": {"rect":[230.0,161.0,200.0,80.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer": {"rect":[230.0,161.0,1071.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox/SoundtracksAddedLabel": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox/SoundtracksChangedContentLabel": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox/SoundtracksChangedTitleLabel": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox/SoundtracksReAddedLabel": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox/SoundtracksRemovedLabel": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTime": {"rect":[230.0,161.0,84.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTitleLabel": {"rect":[230.0,161.0,55.0,35.0],"visible":false}, + "TopBar": {"rect":[0.0,0.0,1920.0,56.0],"visible":true}, + "TopBar/AboutButton": {"rect":[1722.0,8.0,70.0,40.0],"visible":true}, + "TopBar/QRButton": {"rect":[1802.0,8.0,70.0,40.0],"visible":true}, + "TopBar/SearchButton": {"rect":[1354.0,8.0,70.0,40.0],"visible":true}, + "TopBar/ShortcutsButton": {"rect":[1626.0,8.0,83.0,40.0],"visible":true}, + "TopBar/StatisticsButton": {"rect":[1530.0,8.0,78.0,40.0],"visible":true}, + "TopBar/SyncButton": {"rect":[1442.0,8.0,70.0,40.0],"visible":true}, + "TopBar/Title": {"rect":[64.0,12.0,305.0,38.0],"visible":true}, + "TopBar/TopPanelContainer": {"rect":[0.0,0.0,1920.0,56.0],"visible":true} +}, +"sync_buttons_locked": { + ".": {"rect":[0.0,0.0,1920.0,1080.0],"visible":true}, + "AboutPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "AboutPopup/AboutPanel": {"rect":[720.0,153.0,600.0,640.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer": {"rect":[740.0,203.0,560.0,580.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox": {"rect":[740.0,203.0,552.0,2105.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/DescriptionLabel": {"rect":[740.0,230.0,552.0,49.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/VersionLabel": {"rect":[740.0,203.0,552.0,23.0],"visible":false}, + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox/WhatsNewLabel": {"rect":[740.0,283.0,552.0,2025.0],"visible":false}, + "AboutPopup/AboutPanel/TitleLabel": {"rect":[720.0,153.0,600.0,50.0],"visible":false}, + "LogPopup": {"rect":[562.0,132.0,2620.0,1780.0],"visible":false}, + "LogPopup/LogPanel": {"rect":[562.0,132.0,700.0,700.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer": {"rect":[582.0,188.0,660.0,628.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer": {"rect":[582.0,188.0,660.0,86.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer/#0": {"rect":[582.0,188.0,660.0,28.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer/#1": {"rect":[582.0,217.0,660.0,28.0],"visible":false}, + "LogPopup/LogPanel/LogScrollContainer/LogVBoxContainer/#2": {"rect":[582.0,246.0,660.0,28.0],"visible":false}, + "LogPopup/LogPanel/TitleLabel": {"rect":[562.0,140.0,700.0,40.0],"visible":false}, + "PopupBackdrop": {"rect":[0.0,0.0,1920.0,1080.0],"visible":false}, + "QRPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "QRPopup/QRPanel": {"rect":[720.0,153.0,450.0,520.0],"visible":false}, + "QRPopup/QRPanel/QRCodeRect": {"rect":[740.0,173.0,410.0,410.0],"visible":false}, + "QRPopup/QRPanel/URLLabel": {"rect":[740.0,603.0,410.0,40.0],"visible":false}, + "Quiz": {"rect":[0.0,0.0,1920.0,1080.0],"visible":true}, + "Quiz/CharacterSelectPopup": {"rect":[405.0,165.0,1152.0,640.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel": {"rect":[405.0,165.0,1290.0,665.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer": {"rect":[405.0,165.0,1290.0,659.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer": {"rect":[405.0,204.0,1290.0,620.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer": {"rect":[425.0,214.0,1250.0,600.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer": {"rect":[425.0,214.0,1215.0,600.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1": {"rect":[670.0,214.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel": {"rect":[670.0,214.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer": {"rect":[670.0,214.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer": {"rect":[685.0,224.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[685.0,328.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#1/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[690.0,224.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10": {"rect":[425.0,524.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel": {"rect":[425.0,524.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer": {"rect":[425.0,524.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer": {"rect":[440.0,534.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[440.0,638.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#10/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[445.0,534.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11": {"rect":[670.0,524.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel": {"rect":[670.0,524.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer": {"rect":[670.0,524.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer": {"rect":[685.0,534.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[685.0,638.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#11/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[690.0,534.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12": {"rect":[915.0,524.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel": {"rect":[915.0,524.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer": {"rect":[915.0,524.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer": {"rect":[930.0,534.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[930.0,638.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#12/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[935.0,534.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2": {"rect":[915.0,214.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel": {"rect":[915.0,214.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer": {"rect":[915.0,214.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer": {"rect":[930.0,224.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[930.0,328.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#2/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[935.0,224.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3": {"rect":[1160.0,214.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel": {"rect":[1160.0,214.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer": {"rect":[1160.0,214.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer": {"rect":[1175.0,224.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[1175.0,328.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#3/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[1180.0,224.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4": {"rect":[1405.0,214.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel": {"rect":[1405.0,214.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer": {"rect":[1405.0,214.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer": {"rect":[1420.0,224.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[1420.0,328.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#4/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[1425.0,224.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5": {"rect":[425.0,369.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel": {"rect":[425.0,369.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer": {"rect":[425.0,369.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer": {"rect":[440.0,379.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[440.0,483.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#5/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[445.0,379.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6": {"rect":[670.0,369.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel": {"rect":[670.0,369.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer": {"rect":[670.0,369.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer": {"rect":[685.0,379.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[685.0,483.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#6/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[690.0,379.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7": {"rect":[915.0,369.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel": {"rect":[915.0,369.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer": {"rect":[915.0,369.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer": {"rect":[930.0,379.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[930.0,483.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#7/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[935.0,379.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8": {"rect":[1160.0,369.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel": {"rect":[1160.0,369.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer": {"rect":[1160.0,369.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer": {"rect":[1175.0,379.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[1175.0,483.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#8/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[1180.0,379.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9": {"rect":[1405.0,369.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel": {"rect":[1405.0,369.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer": {"rect":[1405.0,369.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer": {"rect":[1420.0,379.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[1420.0,483.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/#9/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[1425.0,379.0,200.0,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard": {"rect":[425.0,214.0,235.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel": {"rect":[425.0,214.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer": {"rect":[425.0,214.0,240.0,145.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer": {"rect":[440.0,224.0,210.0,135.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer/CharacterName": {"rect":[440.0,328.0,210.0,23.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/MarginContainer/ScrollContainer/CharacterGridContainer/CharacterCard/Panel/MarginContainer/VBoxContainer/CharacterTextureRect": {"rect":[444.0,224.0,200.66,100.0],"visible":false}, + "Quiz/CharacterSelectPopup/CharacterPanel/VBoxContainer/TitleLabel": {"rect":[405.0,165.0,1290.0,35.0],"visible":false}, + "Quiz/DebugLabel": {"rect":[974.0,904.0,120.0,69.0],"visible":true}, + "Quiz/FileDialog": {"rect":[0.0,0.0,616.0,400.0],"visible":false}, + "Quiz/InspirationList": {"rect":[1216.0,128.0,641.0,384.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer": {"rect":[1216.0,128.0,641.0,384.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#0": {"rect":[1216.0,128.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#0/#0": {"rect":[1224.0,130.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#1": {"rect":[1216.0,164.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#1/#0": {"rect":[1224.0,166.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#2": {"rect":[1216.0,200.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#2/#0": {"rect":[1224.0,202.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#3": {"rect":[1216.0,236.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#3/#0": {"rect":[1224.0,238.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#4": {"rect":[1216.0,272.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#4/#0": {"rect":[1224.0,274.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#5": {"rect":[1216.0,308.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#5/#0": {"rect":[1224.0,310.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#6": {"rect":[1216.0,344.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#6/#0": {"rect":[1224.0,346.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#7": {"rect":[1216.0,380.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#7/#0": {"rect":[1224.0,382.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#8": {"rect":[1216.0,416.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#8/#0": {"rect":[1224.0,418.0,625.0,28.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#9": {"rect":[1216.0,452.0,641.0,32.0],"visible":true}, + "Quiz/InspirationList/VBoxContainer/#9/#0": {"rect":[1224.0,454.0,625.0,28.0],"visible":true}, + "Quiz/MusicListPanel": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList": {"rect":[64.0,384.0,1088.0,512.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/#1": {"rect":[64.0,422.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/#1/HBoxContainer": {"rect":[64.0,422.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/#1/HBoxContainer/Panel": {"rect":[1068.0,422.0,80.0,0.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/#1/HBoxContainer/SongListLabel": {"rect":[64.0,422.0,1000.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/#1/HBoxContainer/SongListPlayers": {"rect":[1152.0,422.0,0.0,0.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow": {"rect":[64.0,384.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer": {"rect":[64.0,384.0,1088.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/Panel": {"rect":[968.0,384.0,75.0,0.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListLabel": {"rect":[64.0,384.0,900.0,28.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListPlayers": {"rect":[1047.0,384.0,105.0,25.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListPlayers/#0": {"rect":[1047.0,384.0,51.0,25.0],"visible":true}, + "Quiz/MusicListPanel/ScrollContainer/MusicList/SongRow/HBoxContainer/SongListPlayers/#1": {"rect":[1102.0,384.0,50.0,25.0],"visible":true}, + "Quiz/MusicPlayer": {"rect":[64.0,992.0,1792.0,71.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar": {"rect":[65.0,992.0,1791.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer": {"rect":[65.0,992.0,1791.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/MusicPlayerSlider": {"rect":[137.0,1000.0,1514.0,16.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/MusicTimeLabel": {"rect":[1655.0,996.0,97.0,23.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/PlayTextureButton": {"rect":[65.0,992.0,32.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/RestartTextureButton": {"rect":[101.0,992.0,32.0,32.0],"visible":true}, + "Quiz/MusicPlayer/PlayerBar/MusicPlayerContainer/VolumeSlider": {"rect":[1756.0,1000.0,100.0,16.0],"visible":true}, + "Quiz/MusicPlayer/RepeatSongCheckButton": {"rect":[64.0,1032.0,144.0,31.0],"visible":true}, + "Quiz/NowPlaying": {"rect":[64.0,64.0,1088.0,320.0],"visible":true}, + "Quiz/NowPlaying/SongLabel": {"rect":[64.0,137.0,1088.0,49.0],"visible":true}, + "Quiz/NowPlaying/SoundtrackLabel": {"rect":[64.0,64.0,1088.0,69.0],"visible":true}, + "Quiz/Open": {"rect":[1296.0,66.0,50.0,31.0],"visible":false}, + "Quiz/PlayersPanel": {"rect":[1216.0,576.0,641.0,401.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer": {"rect":[1216.0,576.0,641.0,401.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer": {"rect":[1216.0,620.0,641.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/AddPlayerButton": {"rect":[1617.0,620.0,39.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/Panel": {"rect":[1216.0,620.0,196.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/Panel2": {"rect":[1660.0,620.0,197.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/AddPlayerContainer/PlayerNameField": {"rect":[1416.0,620.0,197.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer": {"rect":[1216.0,576.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer/AddPlayersButton": {"rect":[1821.0,576.0,36.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/HBoxContainer/Label": {"rect":[1216.0,576.0,601.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer": {"rect":[1216.0,620.0,641.0,357.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer": {"rect":[1216.0,620.0,641.0,357.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1": {"rect":[1216.0,664.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer": {"rect":[1216.0,664.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/AddPoint": {"rect":[1732.0,664.0,40.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Character": {"rect":[1614.0,664.0,80.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Name": {"rect":[1225.0,664.0,365.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel": {"rect":[1827.0,664.0,30.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel2": {"rect":[1698.0,664.0,30.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel3": {"rect":[1594.0,664.0,16.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel4": {"rect":[1216.0,664.0,5.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/Panel5": {"rect":[1776.0,664.0,3.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/RemovePlayer": {"rect":[1817.0,664.0,40.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/#1/HBoxContainer/RemovePoint": {"rect":[1783.0,664.0,40.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow": {"rect":[1216.0,620.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer": {"rect":[1216.0,620.0,641.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/AddPoint": {"rect":[1732.0,620.0,40.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Character": {"rect":[1614.0,620.0,80.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Name": {"rect":[1225.0,620.0,365.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel": {"rect":[1827.0,620.0,30.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel2": {"rect":[1698.0,620.0,30.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel3": {"rect":[1594.0,620.0,16.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel4": {"rect":[1216.0,620.0,5.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/Panel5": {"rect":[1776.0,620.0,3.0,40.0],"visible":true}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/RemovePlayer": {"rect":[1817.0,620.0,40.0,40.0],"visible":false}, + "Quiz/PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer/PlayerRow/HBoxContainer/RemovePoint": {"rect":[1783.0,620.0,40.0,40.0],"visible":true}, + "Quiz/PopupBackdrop": {"rect":[0.0,0.0,1920.0,1080.0],"visible":false}, + "Quiz/QuizToolbar": {"rect":[1360.0,64.0,495.0,31.0],"visible":true}, + "Quiz/QuizToolbar/ResetPlaylistButton": {"rect":[1360.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/ResetPointsButton": {"rect":[1488.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/SettingsButton": {"rect":[1616.0,64.0,111.0,31.0],"visible":true}, + "Quiz/QuizToolbar/SoundTestButton": {"rect":[1744.0,64.0,111.0,31.0],"visible":true}, + "Quiz/SettingsPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel": {"rect":[720.0,153.0,500.0,640.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer": {"rect":[740.0,161.0,460.0,624.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer": {"rect":[740.0,161.0,460.0,455.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/AddToDatabaseCheckButton": {"rect":[740.0,270.0,460.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer": {"rect":[740.0,480.0,460.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/CacheLabel": {"rect":[1084.0,484.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/CacheTitleLabel": {"rect":[740.0,484.0,340.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/IncreaseCacheButton": {"rect":[1170.0,480.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer/LowerCacheButton": {"rect":[1136.0,480.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/FullscreenButton": {"rect":[740.0,585.0,460.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer": {"rect":[740.0,445.0,460.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/IncreaseButton": {"rect":[1170.0,445.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/Label2": {"rect":[740.0,449.0,340.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/LowerButton": {"rect":[1136.0,445.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer/ScoreLabel": {"rect":[1084.0,449.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideBeginningCheckButton": {"rect":[740.0,340.0,460.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer": {"rect":[740.0,161.0,274.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/HideForSecondsLabel": {"rect":[740.0,161.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/HideForSecondsTitle": {"rect":[740.0,161.0,154.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/IncreaseHideForSecondsButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideForSecondsHBoxContainer/LowerHideForSecondsButton": {"rect":[740.0,161.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideLengthCheckButton": {"rect":[740.0,375.0,460.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideNextTrackCheckButton": {"rect":[740.0,235.0,460.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/HideTicksCheckButton": {"rect":[740.0,410.0,460.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer": {"rect":[740.0,515.0,460.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/IncreaseInspirationSpeedButton": {"rect":[1170.0,515.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/InspirationListSpeedLabel": {"rect":[1084.0,519.0,48.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/InspirationListSpeedTitle": {"rect":[740.0,519.0,340.0,23.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/InspirationHBoxContainer/LowerInspirationSpeedButton": {"rect":[1136.0,515.0,30.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/Label": {"rect":[740.0,161.0,460.0,35.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/LocalButton": {"rect":[740.0,161.0,89.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/LowPlayedCheckButton": {"rect":[740.0,305.0,460.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/QuickSyncButton": {"rect":[740.0,161.0,131.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/SelectServerButton": {"rect":[740.0,550.0,460.0,31.0],"visible":false}, + "Quiz/SettingsPopup/SettingsPanel/ScrollContainer/VBoxContainer/StopAfterCurrentCheckButton": {"rect":[740.0,200.0,460.0,31.0],"visible":false}, + "Quiz/SongControls": {"rect":[65.0,944.0,303.0,31.0],"visible":true}, + "Quiz/SongControls/NextButton": {"rect":[193.0,944.0,175.0,31.0],"visible":true}, + "Quiz/SongControls/ShowAnswerButton": {"rect":[65.0,944.0,111.0,31.0],"visible":true}, + "Quiz/WinnerPopup": {"rect":[720.0,153.0,1920.0,1080.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel": {"rect":[720.0,153.0,500.0,600.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer": {"rect":[720.0,178.0,500.0,280.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/PlayersList": {"rect":[720.0,435.0,500.0,23.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/PlayersList/#0": {"rect":[720.0,435.0,500.0,23.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/WinnerLabel": {"rect":[720.0,382.0,500.0,49.0],"visible":false}, + "Quiz/WinnerPopup/WinnerPanel/VBoxContainer/WinnerPicture": {"rect":[720.0,178.0,500.0,200.0],"visible":false}, + "SearchPopup": {"rect":[56.0,96.0,1920.0,1080.0],"visible":false}, + "SearchPopup/ClearButton": {"rect":[992.0,112.0,182.0,80.0],"visible":false}, + "SearchPopup/ScrollContainer": {"rect":[56.0,224.0,1152.0,512.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults": {"rect":[56.0,224.0,1136.0,532.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#0": {"rect":[56.0,224.0,1136.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#1": {"rect":[56.0,278.0,1136.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#2": {"rect":[56.0,332.0,1136.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#3": {"rect":[56.0,386.0,1136.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#4": {"rect":[56.0,440.0,1136.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#5": {"rect":[56.0,494.0,1136.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#6": {"rect":[56.0,548.0,1136.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#7": {"rect":[56.0,602.0,1136.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#8": {"rect":[56.0,656.0,1136.0,46.0],"visible":false}, + "SearchPopup/ScrollContainer/SearchResults/#9": {"rect":[56.0,710.0,1136.0,46.0],"visible":false}, + "SearchPopup/SearchPanel": {"rect":[56.0,96.0,1152.0,640.0],"visible":false}, + "SearchPopup/Searchbar": {"rect":[88.0,112.0,872.0,80.0],"visible":false}, + "ShortcutsPopup": {"rect":[562.0,160.0,2620.0,1780.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox": {"rect":[562.0,160.0,700.0,700.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#0": {"rect":[562.0,160.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1": {"rect":[562.0,192.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0": {"rect":[562.0,192.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0/#0": {"rect":[562.0,192.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#1/#0/#1": {"rect":[914.0,192.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10": {"rect":[562.0,480.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0": {"rect":[562.0,480.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0/#0": {"rect":[562.0,480.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#10/#0/#1": {"rect":[914.0,480.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11": {"rect":[562.0,512.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0": {"rect":[562.0,512.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0/#0": {"rect":[562.0,512.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#11/#0/#1": {"rect":[914.0,512.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12": {"rect":[562.0,544.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0": {"rect":[562.0,544.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0/#0": {"rect":[562.0,544.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#12/#0/#1": {"rect":[914.0,544.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13": {"rect":[562.0,576.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0": {"rect":[562.0,576.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0/#0": {"rect":[562.0,576.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#13/#0/#1": {"rect":[914.0,576.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14": {"rect":[562.0,608.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0": {"rect":[562.0,608.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0/#0": {"rect":[562.0,608.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#14/#0/#1": {"rect":[914.0,608.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15": {"rect":[562.0,640.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0": {"rect":[562.0,640.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0/#0": {"rect":[562.0,640.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#15/#0/#1": {"rect":[914.0,640.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16": {"rect":[562.0,672.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0": {"rect":[562.0,672.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0/#0": {"rect":[562.0,672.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#16/#0/#1": {"rect":[914.0,672.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2": {"rect":[562.0,224.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0": {"rect":[562.0,224.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0/#0": {"rect":[562.0,224.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#2/#0/#1": {"rect":[914.0,224.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3": {"rect":[562.0,256.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0": {"rect":[562.0,256.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0/#0": {"rect":[562.0,256.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#3/#0/#1": {"rect":[914.0,256.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4": {"rect":[562.0,288.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0": {"rect":[562.0,288.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0/#0": {"rect":[562.0,288.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#4/#0/#1": {"rect":[914.0,288.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5": {"rect":[562.0,320.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0": {"rect":[562.0,320.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0/#0": {"rect":[562.0,320.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#5/#0/#1": {"rect":[914.0,320.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6": {"rect":[562.0,352.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0": {"rect":[562.0,352.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0/#0": {"rect":[562.0,352.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#6/#0/#1": {"rect":[914.0,352.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7": {"rect":[562.0,384.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0": {"rect":[562.0,384.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0/#0": {"rect":[562.0,384.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#7/#0/#1": {"rect":[914.0,384.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8": {"rect":[562.0,416.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0": {"rect":[562.0,416.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0/#0": {"rect":[562.0,416.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#8/#0/#1": {"rect":[914.0,416.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9": {"rect":[562.0,448.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0": {"rect":[562.0,448.0,700.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0/#0": {"rect":[562.0,448.0,348.0,28.0],"visible":false}, + "ShortcutsPopup/ShortcutsPanel/ScrollContainer/shortcutsVBox/#9/#0/#1": {"rect":[914.0,448.0,348.0,28.0],"visible":false}, + "StatisticsPopupPanel": {"rect":[735.0,490.0,450.0,100.0],"visible":false}, + "StatisticsPopupPanel/Label": {"rect":[12.0,12.0,426.0,76.0],"visible":false}, + "StatisticsPopupPanel/StatisticsLabel": {"rect":[12.0,12.0,426.0,76.0],"visible":false}, + "SyncPopup": {"rect":[210.0,153.0,1920.0,1080.0],"visible":false}, + "SyncPopup/SyncPanel": {"rect":[210.0,153.0,1500.0,640.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer": {"rect":[230.0,161.0,1460.0,624.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer": {"rect":[230.0,161.0,1460.0,109.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox/CaughtErrorsLabel": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/NoChangesLabel": {"rect":[230.0,161.0,319.0,35.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer": {"rect":[230.0,243.0,1460.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer/SyncProgressBar": {"rect":[310.0,243.0,1300.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/RestartSyncButton": {"rect":[230.0,161.0,200.0,80.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer": {"rect":[230.0,161.0,1071.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox/SoundtracksAddedLabel": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox/SoundtracksChangedContentLabel": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox/SoundtracksChangedTitleLabel": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox/SoundtracksReAddedLabel": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox/SoundtracksRemovedLabel": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTime": {"rect":[230.0,208.0,1460.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTitleLabel": {"rect":[230.0,161.0,1460.0,35.0],"visible":false}, + "TopBar": {"rect":[0.0,0.0,1920.0,56.0],"visible":true}, + "TopBar/AboutButton": {"rect":[1722.0,8.0,70.0,40.0],"visible":true}, + "TopBar/QRButton": {"rect":[1802.0,8.0,70.0,40.0],"visible":true}, + "TopBar/SearchButton": {"rect":[1354.0,8.0,70.0,40.0],"visible":true}, + "TopBar/ShortcutsButton": {"rect":[1626.0,8.0,83.0,40.0],"visible":true}, + "TopBar/StatisticsButton": {"rect":[1530.0,8.0,78.0,40.0],"visible":true}, + "TopBar/SyncButton": {"rect":[1442.0,8.0,70.0,40.0],"visible":true}, + "TopBar/Title": {"rect":[64.0,12.0,305.0,38.0],"visible":true}, + "TopBar/TopPanelContainer": {"rect":[0.0,0.0,1920.0,56.0],"visible":true} +}, +"sync_in_progress": { + "SyncPopup": {"rect":[210.0,153.0,1920.0,1080.0],"visible":true}, + "SyncPopup/SyncPanel": {"rect":[210.0,153.0,1500.0,640.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer": {"rect":[230.0,161.0,1460.0,624.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer": {"rect":[230.0,161.0,1460.0,109.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox/CaughtErrorsLabel": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/NoChangesLabel": {"rect":[230.0,161.0,319.0,35.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer": {"rect":[230.0,243.0,1460.0,27.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer/SyncProgressBar": {"rect":[310.0,243.0,1300.0,27.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/RestartSyncButton": {"rect":[230.0,161.0,200.0,80.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer": {"rect":[230.0,161.0,1071.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox/SoundtracksAddedLabel": {"rect":[230.0,161.0,147.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox/SoundtracksChangedContentLabel": {"rect":[230.0,161.0,227.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox/SoundtracksChangedTitleLabel": {"rect":[230.0,161.0,198.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox/SoundtracksReAddedLabel": {"rect":[230.0,161.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedSeparator": {"rect":[230.0,161.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox/SoundtracksRemovedLabel": {"rect":[230.0,161.0,169.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTime": {"rect":[230.0,208.0,1460.0,23.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTitleLabel": {"rect":[230.0,161.0,1460.0,35.0],"visible":true} +}, +"sync_result": { + "SyncPopup": {"rect":[210.0,153.0,1920.0,1080.0],"visible":true}, + "SyncPopup/SyncPanel": {"rect":[210.0,153.0,1500.0,640.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer": {"rect":[230.0,161.0,1460.0,624.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer": {"rect":[230.0,161.0,1460.0,294.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/CaughtErrorsVBox/CaughtErrorsLabel": {"rect":[230.0,161.0,111.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/NoChangesLabel": {"rect":[230.0,161.0,319.0,35.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer": {"rect":[230.0,243.0,1460.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer/SyncProgressBar": {"rect":[310.0,243.0,1300.0,27.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/RestartSyncButton": {"rect":[860.0,375.0,200.0,80.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer": {"rect":[230.0,243.0,1460.0,120.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedSeparator": {"rect":[584.0,243.0,4.0,120.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox": {"rect":[230.0,243.0,338.0,120.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox/#0": {"rect":[230.0,243.0,338.0,28.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox/#1": {"rect":[230.0,275.0,338.0,30.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox/#1/#0": {"rect":[238.0,277.0,322.0,26.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox/#2": {"rect":[230.0,309.0,338.0,30.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksAddedVBox/#2/#0": {"rect":[238.0,311.0,322.0,26.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentSeparator": {"rect":[1332.0,243.0,4.0,120.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox": {"rect":[978.0,243.0,338.0,120.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox/#0": {"rect":[978.0,243.0,338.0,28.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox/#1": {"rect":[978.0,275.0,338.0,30.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedContentVBox/#1/#0": {"rect":[986.0,277.0,322.0,26.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleSeparator": {"rect":[958.0,243.0,4.0,120.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox": {"rect":[604.0,243.0,338.0,120.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox/#0": {"rect":[604.0,243.0,338.0,28.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox/#1": {"rect":[604.0,275.0,338.0,88.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksChangedTitleVBox/#1/#0": {"rect":[612.0,277.0,322.0,84.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedSeparator": {"rect":[230.0,243.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox": {"rect":[230.0,243.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksReAddedVBox/SoundtracksReAddedLabel": {"rect":[230.0,243.0,166.0,23.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedSeparator": {"rect":[230.0,243.0,4.0,3.0],"visible":false}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox": {"rect":[1352.0,243.0,338.0,120.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox/#0": {"rect":[1352.0,243.0,338.0,28.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox/#1": {"rect":[1352.0,275.0,338.0,30.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/SoundtracksRemovedVBox/#1/#0": {"rect":[1360.0,277.0,322.0,26.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTime": {"rect":[230.0,208.0,1460.0,23.0],"visible":true}, + "SyncPopup/SyncPanel/ScrollContainer/SyncVBoxContainer/SyncTitleLabel": {"rect":[230.0,161.0,1460.0,35.0],"visible":true} +} +} diff --git a/tests/data/short.mp3 b/tests/data/short.mp3 new file mode 100644 index 0000000..4235045 Binary files /dev/null and b/tests/data/short.mp3 differ diff --git a/tests/fakes/autoload_snapshot.gd b/tests/fakes/autoload_snapshot.gd new file mode 100644 index 0000000..341992c --- /dev/null +++ b/tests/fakes/autoload_snapshot.gd @@ -0,0 +1,27 @@ +class_name AutoloadSnapshot +extends RefCounted +## Saves and restores the script variables of autoloads such as Settings and Playlist, +## so a test that changes global state does not leak into the next test. + +var _values: Dictionary = {} + + +## Records the current values of every script variable on [param autoloads]. +static func take(autoloads: Array[Node]) -> AutoloadSnapshot: + var snapshot: AutoloadSnapshot = AutoloadSnapshot.new() + for autoload: Node in autoloads: + var values: Dictionary = {} + for property: Dictionary in autoload.get_property_list(): + if property.usage & PROPERTY_USAGE_SCRIPT_VARIABLE: + var value: Variant = autoload.get(property.name) + values[property.name] = value.duplicate() if value is Array else value + snapshot._values[autoload] = values + return snapshot + + +## Puts back the values recorded by [method take]. +func restore() -> void: + for autoload: Node in _values: + var values: Dictionary = _values[autoload] + for property_name: String in values: + autoload.set(property_name, values[property_name]) diff --git a/tests/fakes/autoload_snapshot.gd.uid b/tests/fakes/autoload_snapshot.gd.uid new file mode 100644 index 0000000..328878e --- /dev/null +++ b/tests/fakes/autoload_snapshot.gd.uid @@ -0,0 +1 @@ +uid://d1l1oehlhm5ss diff --git a/tests/fakes/fake_http_transport.gd b/tests/fakes/fake_http_transport.gd new file mode 100644 index 0000000..0686c43 --- /dev/null +++ b/tests/fakes/fake_http_transport.gd @@ -0,0 +1,91 @@ +class_name FakeHttpTransport +extends HttpTransport +## Stands in for the MusicServer in tests. +## +## Register canned responses per path with [method respond_json] or +## [method respond_bytes], then install it with [method install]. Requests to paths +## without a response get a 404. Responses are delivered immediately. + +## Paths requested so far, relative to [code]Settings.default_path[/code]. +var requested_paths: Array[String] = [] +## HTTP method of each request, in the same order as [member requested_paths]. +var requested_methods: Array[int] = [] + +var _responses: Dictionary = {} +var _successes_left: Dictionary = {} +var _held_paths: Dictionary = {} +var _previous_transport: HttpTransport + + +func _init() -> void: + super(null) + + +## Replaces the MusicServerClient transport with this fake. +func install() -> FakeHttpTransport: + _previous_transport = MusicServerClient.transport + MusicServerClient.transport = self + return self + + +## Restores the transport that was active before [method install]. +func uninstall() -> void: + MusicServerClient.transport = _previous_transport + + +## Answers requests to [param path] with [param data] encoded as JSON. +func respond_json(path: String, data: Variant, response_code: int = 200) -> void: + respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code) + + +## Answers requests to [param path] with [param body] as-is. +func respond_bytes(path: String, body: PackedByteArray, response_code: int = 200) -> void: + _responses[path] = {"code": response_code, "body": body} + _successes_left.erase(path) + + +## Lets [param successes] more requests to [param path] succeed, then answers 503 until +## a new response is registered for the path. +func fail_after(path: String, successes: int) -> void: + _successes_left[path] = successes + + +## Holds back responses to [param path] until [method release] is called, like a slow +## server. +func hold(path: String) -> void: + _held_paths[path] = [] + + +## Delivers the responses held back for [param path] and stops holding it. +func release(path: String) -> void: + var waiting: Array = _held_paths.get(path, []) + _held_paths.erase(path) + for pending: Callable in waiting: + pending.call() + + +## Returns how many times [param path] has been requested. +func count(path: String) -> int: + return requested_paths.count(path) + + +func request(url: String, method: int, on_complete: Callable) -> void: + var path: String = url.trim_prefix(Settings.default_path) + requested_paths.append(path) + requested_methods.append(method) + if _held_paths.has(path): + _held_paths[path].append(_answer.bind(path, on_complete)) + return + _answer(path, on_complete) + + +func _answer(path: String, on_complete: Callable) -> void: + if _successes_left.has(path): + if _successes_left[path] == 0: + on_complete.call(503, PackedByteArray()) + return + _successes_left[path] -= 1 + if _responses.has(path): + on_complete.call(_responses[path].code, _responses[path].body) + else: + on_complete.call(404, PackedByteArray()) diff --git a/tests/fakes/fake_http_transport.gd.uid b/tests/fakes/fake_http_transport.gd.uid new file mode 100644 index 0000000..387ad8b --- /dev/null +++ b/tests/fakes/fake_http_transport.gd.uid @@ -0,0 +1 @@ +uid://blmtk4sw1iylj diff --git a/tests/fakes/main_window_test_suite.gd b/tests/fakes/main_window_test_suite.gd new file mode 100644 index 0000000..0c81848 --- /dev/null +++ b/tests/fakes/main_window_test_suite.gd @@ -0,0 +1,134 @@ +class_name MainWindowTestSuite +extends GdUnitTestSuite +## Base for tests that run the real main window against a fake MusicServer. + +const SONG_INFO: Array[Dictionary] = [ + {"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time"}, + {"SongNo": 1, "Soundtrack": "Final Fantasy VI", "Song": "Dancing Mad"}, +] + +var _server: FakeHttpTransport +var _snapshot: AutoloadSnapshot +var _runner: GdUnitSceneRunner +var _window: Control +var _quiz: Quiz + + +func before_test() -> void: + _snapshot = AutoloadSnapshot.take([Settings, Playlist]) + Playlist.reset_playlist() + Playlist.first_song_played = false + Playlist.is_song_downloading = false + Settings.player_array.clear() + Settings.add_to_stats = true + Settings.hide_next_track = true + Settings.use_low_played_mode = false + Settings.winning_score = 20 + + _server = FakeHttpTransport.new().install() + var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3") + _server.respond_json("/music/all", ["Chrono Trigger", "Final Fantasy VI"]) + _server.respond_json("/music/all/order", ["Chrono Trigger", "Final Fantasy VI"]) + _server.respond_json("/music/list", []) + _server.respond_json("/characters", []) + _server.respond_bytes("/music/rand", mp3) + _server.respond_bytes("/music/addQue", PackedByteArray()) + _server.respond_json("/music/info", SONG_INFO[0]) + _server.respond_bytes("/music/reset", PackedByteArray()) + _server.respond_bytes("/music/played?song=0", PackedByteArray()) + + _before_window_starts() + _runner = scene_runner("res://main/MainWindow.tscn") + _window = _runner.scene() + _quiz = _window.get_node("Quiz") + # The debug build lowers the winning score on startup; keep it out of reach + Settings.winning_score = 20 + + +## Override to change the fake server before the window starts and sends its first +## requests. +func _before_window_starts() -> void: + pass + + +func after_test() -> void: + # Let nodes queued for deletion by the window be freed before orphans are counted + await await_idle_frame() + _server.uninstall() + _snapshot.restore() + + +## Makes the next song the server hands out the one described by [param info]. +func _next_song_is(info: Dictionary) -> void: + _server.respond_json("/music/info", info) + + +## Presses a button in the quiz. +func _press(button_path: String) -> void: + (_quiz.get_node(button_path) as Button).pressed.emit() + + +## Presses Next and waits until the first song is playing. +func _play_first_song() -> void: + _press("SongControls/NextButton") + await _runner.simulate_frames(2) + + +## Adds a player through the name field, the same way the user does. +func _add_player(player_name: String) -> PlayerObject: + var name_field: TextEdit = _quiz.get_node( + "PlayersPanel/VBoxContainer/AddPlayerContainer/PlayerNameField") + name_field.text = player_name + _quiz.players_panel.add_player() + return Settings.player_array.back() if !Settings.player_array.is_empty() else null + + +## Returns the row in the player list that shows [param player_object]. +func _player_row(player_object: PlayerObject) -> PlayerRow: + var rows: Node = _quiz.get_node( + "PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer") + for row: PlayerRow in rows.get_children(): + if row.player_object == player_object: + return row + return null + + +## Removes a player with the remove button in its row. +func _remove_player(player_object: PlayerObject) -> void: + (_player_row(player_object).get_node("HBoxContainer/RemovePlayer") as BaseButton) \ + .pressed.emit() + await _runner.simulate_frames(1) + + +## Clicks at [param position], given in the window's 1920x1080 layout coordinates. +func _click_at(position: Vector2) -> void: + # The runner takes window pixels; the headless test window is much smaller than the + # layout, so convert like a real mouse position would be + _runner.set_mouse_position(_window.get_viewport().get_final_transform() * position) + await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_LEFT) + await _runner.await_input_processed() + + +## Presses [param keys] together, e.g. [code][KEY_ALT, KEY_1][/code], then releases them. +func _press_keys(keys: Array[Key]) -> void: + for key: Key in keys: + _runner.simulate_key_press(key) + await _runner.await_input_processed() + keys.reverse() + for key: Key in keys: + _runner.simulate_key_release(key) + await _runner.await_input_processed() + + +## Gives a point with Alt+. +func _give_point_with_keybind(number: int) -> void: + await _press_keys([KEY_ALT, (KEY_0 + number) as Key]) + + +## Takes a point with Ctrl+Alt+. +func _take_point_with_keybind(number: int) -> void: + await _press_keys([KEY_CTRL, KEY_ALT, (KEY_0 + number) as Key]) + + +func _scores() -> Array: + return Settings.player_array.map(func(p: PlayerObject) -> int: return p.player_score) diff --git a/tests/fakes/main_window_test_suite.gd.uid b/tests/fakes/main_window_test_suite.gd.uid new file mode 100644 index 0000000..0d1ed5b --- /dev/null +++ b/tests/fakes/main_window_test_suite.gd.uid @@ -0,0 +1 @@ +uid://cjplwhulmw00 diff --git a/tests/fakes/ui_tour.gd b/tests/fakes/ui_tour.gd new file mode 100644 index 0000000..bd9627f --- /dev/null +++ b/tests/fakes/ui_tour.gd @@ -0,0 +1,284 @@ +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 diff --git a/tests/fakes/ui_tour.gd.uid b/tests/fakes/ui_tour.gd.uid new file mode 100644 index 0000000..c6c943b --- /dev/null +++ b/tests/fakes/ui_tour.gd.uid @@ -0,0 +1 @@ +uid://v42qdc68bn17 diff --git a/tests/integration/test_inspiration_list.gd b/tests/integration/test_inspiration_list.gd new file mode 100644 index 0000000..31e8890 --- /dev/null +++ b/tests/integration/test_inspiration_list.gd @@ -0,0 +1,92 @@ +extends GdUnitTestSuite +## The inspiration list on its own: it shows every soundtrack from the server and +## scrolls by itself. + +const SOUNDTRACKS: Array[String] = ["Chrono Trigger", "Final Fantasy VI", "Secret of Mana"] + +var _snapshot: AutoloadSnapshot +var _server: FakeHttpTransport +var _runner: GdUnitSceneRunner +var _list: InspirationList + + +func before_test() -> void: + _snapshot = AutoloadSnapshot.take([Settings]) + Settings.inspiration_list_speed = 0 + _server = FakeHttpTransport.new().install() + _server.respond_json("/music/all", SOUNDTRACKS) + _runner = scene_runner("res://quiz/InspirationList.tscn") + _list = _runner.scene() + + +func after_test() -> void: + await await_idle_frame() + _server.uninstall() + _snapshot.restore() + + +func _rows() -> Array[Node]: + return _list.get_node("VBoxContainer").get_children() + + +func _texts() -> Array: + return _rows().map(func(row: ListRow) -> String: return row.text) + + +func test_load_from_server_shows_every_soundtrack_in_order() -> void: + _list.load_from_server() + + assert_array(_texts()).contains_exactly(SOUNDTRACKS) + assert_array(_list.soundtracks).contains_exactly(SOUNDTRACKS) + + +func test_rows_alternate_backgrounds() -> void: + _list.load_from_server() + + var rows: Array[Node] = _rows() + assert_that((rows[0] as ListRow).get_theme_stylebox("panel")) \ + .is_not_equal((rows[1] as ListRow).get_theme_stylebox("panel")) + assert_that((rows[0] as ListRow).get_theme_stylebox("panel")) \ + .is_equal((rows[2] as ListRow).get_theme_stylebox("panel")) + + +func test_loading_again_replaces_the_rows() -> void: + _list.load_from_server() + _server.respond_json("/music/all", ["Terranigma"]) + + _list.load_from_server() + + assert_array(_texts()).contains_exactly(["Terranigma"]) + + +func test_failed_load_keeps_the_rows() -> void: + _list.load_from_server() + _server.respond_bytes("/music/all", PackedByteArray(), 500) + + _list.load_from_server() + + assert_array(_texts()).contains_exactly(SOUNDTRACKS) + + +func _long_list() -> Array[String]: + var names: Array[String] = [] + for i: int in 40: + names.append("Soundtrack %d" % i) + return names + + +func test_scrolls_down_by_itself() -> void: + _list.show_soundtracks(_long_list()) + Settings.inspiration_list_speed = 5 + + await _runner.simulate_frames(30, 16) + + assert_int(_list.scroll_vertical).is_greater(0) + + +func test_does_not_scroll_at_speed_zero() -> void: + _list.show_soundtracks(_long_list()) + + await _runner.simulate_frames(30, 16) + + assert_int(_list.scroll_vertical).is_equal(0) diff --git a/tests/integration/test_inspiration_list.gd.uid b/tests/integration/test_inspiration_list.gd.uid new file mode 100644 index 0000000..cb05a55 --- /dev/null +++ b/tests/integration/test_inspiration_list.gd.uid @@ -0,0 +1 @@ +uid://2hfs3x1k46wu diff --git a/tests/integration/test_layout_unchanged.gd b/tests/integration/test_layout_unchanged.gd new file mode 100644 index 0000000..635419b --- /dev/null +++ b/tests/integration/test_layout_unchanged.gd @@ -0,0 +1,84 @@ +extends GdUnitTestSuite +## Checks that every node of the main window is where it was when the baseline was taken. +## +## [code]tests/data/layout_baseline.json[/code] is written by +## [code]tools/take_screenshots.gd[/code] (run it with --headless and copy its layout.json). +## It holds the position, size and visibility of every node in each UiTour state, keyed +## by the node's path from the main window. Take a new baseline when the layout is +## meant to change, e.g. after moving a button. + +const BASELINE_PATH: String = "res://tests/data/layout_baseline.json" + +## Nodes whose height follows text that changes between versions, such as the changelog. +## Their position and width are still checked. +const HEIGHT_FOLLOWS_TEXT: Array[String] = [ + "AboutPopup/AboutPanel/ScrollContainer/ContentVBox", +] + +## How far a node may be from its old place, in pixels. +const TOLERANCE: float = 0.5 + +var _snapshot: AutoloadSnapshot +var _server: FakeHttpTransport +var _main: Control +var _layout: Dictionary = {} + + +func before() -> void: + _snapshot = AutoloadSnapshot.take([Settings, Playlist]) + UiTour.prepare_autoloads() + _server = UiTour.make_server().install() + # Laid out at the size the baseline was taken at, like the screenshots + var viewport: SubViewport = auto_free(SubViewport.new()) + viewport.size = Vector2i(1920, 1080) + viewport.gui_embed_subwindows = true + Engine.get_main_loop().root.add_child(viewport) + _main = load("res://main/MainWindow.tscn").instantiate() + viewport.add_child(_main) + await UiTour.new(_main).run(func(state: String, popup: Node) -> void: + _layout[state] = UiTour.dump_layout(_main, _main if popup == null else popup)) + + +func after() -> void: + _main.free() + await Engine.get_main_loop().process_frame + _server.uninstall() + _snapshot.restore() + + +func _baseline() -> Dictionary: + return JSON.parse_string(FileAccess.get_file_as_string(BASELINE_PATH)) + + +func test_tour_has_the_same_states_as_the_baseline() -> void: + assert_array(_layout.keys()).contains_exactly_in_any_order(_baseline().keys()) + + +func test_every_node_is_where_it_was() -> void: + var problems: Array[String] = [] + var baseline: Dictionary = _baseline() + for state: String in baseline: + var actual: Dictionary = _layout.get(state, {}) + for path: String in baseline[state]: + var expected: Dictionary = baseline[state][path] + if !actual.has(path): + problems.append("%s: %s is missing" % [state, path]) + continue + var rect: Array = actual[path].rect + var sides: int = 3 if _height_follows_text(path) else 4 + for i: int in sides: + if absf(rect[i] - expected.rect[i]) > TOLERANCE: + problems.append("%s: %s is at %s, was %s" + % [state, path, rect, expected.rect]) + break + if actual[path].visible != expected.visible: + problems.append("%s: %s visible is %s, was %s" + % [state, path, actual[path].visible, expected.visible]) + assert_array(problems).override_failure_message( + "%d nodes moved:\n%s" % [problems.size(), "\n".join(problems.slice(0, 30))]) \ + .is_empty() + + +static func _height_follows_text(path: String) -> bool: + return HEIGHT_FOLLOWS_TEXT.any(func(node_path: String) -> bool: + return path == node_path || path.begins_with(node_path + "/")) diff --git a/tests/integration/test_layout_unchanged.gd.uid b/tests/integration/test_layout_unchanged.gd.uid new file mode 100644 index 0000000..547ae30 --- /dev/null +++ b/tests/integration/test_layout_unchanged.gd.uid @@ -0,0 +1 @@ +uid://b2077235y1g0f diff --git a/tests/integration/test_main_window.gd b/tests/integration/test_main_window.gd new file mode 100644 index 0000000..21e450d --- /dev/null +++ b/tests/integration/test_main_window.gd @@ -0,0 +1,242 @@ +extends MainWindowTestSuite +## Main window flows: startup, playing songs and resetting. + + +func test_startup_loads_soundtracks_and_preloads_a_song() -> void: + assert_int(_server.count("/music/list")).is_equal(1) + assert_int(_server.count("/music/all")).is_equal(1) + assert_array(_server.requested_paths).contains(["/music/rand", "/music/addQue", "/music/info"]) + assert_int(Playlist.song_object_array.size()).is_equal(1) + assert_bool((_quiz.get_node("SongControls/NextButton") as Button).disabled).is_false() + + +func test_low_played_mode_fetches_from_low_played_endpoint() -> void: + Settings.use_low_played_mode = true + _server.respond_bytes( + "/music/rand/low", FileAccess.get_file_as_bytes("res://tests/data/short.mp3")) + + _quiz.song_loader.fetch_song() + + assert_int(_server.count("/music/rand/low")).is_equal(1) + + +func test_next_plays_preloaded_song_and_fetches_another() -> void: + _next_song_is(SONG_INFO[1]) + + _press("SongControls/NextButton") + await _runner.simulate_frames(2) + + assert_int(Playlist.currently_playing_song).is_equal(0) + assert_bool(Playlist.song_object_array[0].has_played).is_true() + assert_int(Playlist.song_object_array.size()).is_equal(2) + assert_int(_server.count("/music/rand")).is_equal(2) + + +func test_title_is_hidden_until_answer_is_shown() -> void: + var soundtrack_label: Label = _quiz.get_node("NowPlaying/SoundtrackLabel") + _press("SongControls/NextButton") + await _runner.simulate_frames(2) + + assert_str(soundtrack_label.text).is_equal("????????") + + _press("SongControls/ShowAnswerButton") + + assert_str(soundtrack_label.text).is_equal("Chrono Trigger") + assert_int(_server.count("/music/played?song=0")).is_equal(1) + + +func test_reset_playlist_clears_server_and_preloads_again() -> void: + _press("SongControls/NextButton") + await _runner.simulate_frames(2) + + _press("QuizToolbar/ResetPlaylistButton") + + assert_int(_server.count("/music/reset")).is_equal(1) + assert_int(Playlist.currently_playing_song).is_equal(-1) + assert_int(Playlist.song_object_array.size()).is_equal(1) + assert_bool(Playlist.song_object_array[0].has_played).is_false() + + +func test_reset_points_sets_all_scores_to_zero() -> void: + _add_player("Crono").add_point() + _add_player("Marle").add_point() + + _press("QuizToolbar/ResetPointsButton") + + assert_int(Settings.player_array[0].player_score).is_equal(0) + assert_int(Settings.player_array[1].player_score).is_equal(0) + + +func test_cannot_add_more_than_max_players() -> void: + for i: int in Settings.MAX_NUMBER_OF_PLAYERS + 1: + _add_player("Player %d" % i) + + assert_int(Settings.player_array.size()).is_equal(Settings.MAX_NUMBER_OF_PLAYERS) + + +func test_cannot_add_player_without_name() -> void: + _add_player("") + + assert_array(Settings.player_array).is_empty() + + +func test_failed_song_download_is_retried_until_the_server_answers() -> void: + _quiz.song_loader.retry_seconds = 0.05 + var songs_before: int = Playlist.song_object_array.size() + var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3") + _server.respond_bytes("/music/rand", PackedByteArray(), 503) + var rand_requests_before: int = _server.count("/music/rand") + + _quiz.song_loader.fetch_song() + await await_millis(200) + + assert_int(_server.count("/music/rand") - rand_requests_before).is_greater(1) + assert_int(Playlist.song_object_array.size()).is_equal(songs_before) + assert_bool(Playlist.is_song_downloading).is_true() + + _server.respond_bytes("/music/rand", mp3) + await await_millis(200) + + assert_int(Playlist.song_object_array.size()).is_equal(songs_before + 1) + assert_bool(Playlist.is_song_downloading).is_false() + + +func test_failed_song_info_retries_only_that_step() -> void: + _quiz.song_loader.retry_seconds = 0.05 + var songs_before: int = Playlist.song_object_array.size() + _server.respond_bytes("/music/info", PackedByteArray(), 503) + var rand_before: int = _server.count("/music/rand") + var queue_before: int = _server.count("/music/addQue") + + _quiz.song_loader.fetch_song() + await await_millis(200) + _next_song_is(SONG_INFO[1]) + await await_millis(200) + + # The song was downloaded and queued once; only the info request was repeated + assert_int(_server.count("/music/rand") - rand_before).is_equal(1) + assert_int(_server.count("/music/addQue") - queue_before).is_equal(1) + assert_int(Playlist.song_object_array.size()).is_equal(songs_before + 1) + assert_str(Playlist.song_object_array.back().song_title).is_equal("Dancing Mad") + assert_bool(Playlist.is_song_downloading).is_false() + + +func test_preloads_as_many_songs_as_the_cache_setting() -> void: + Playlist.number_of_tracks_to_preload = 3 + + _quiz.song_loader.fetch_song() + + assert_int(Playlist.get_preloaded_songs()).is_equal(3) + assert_bool(Playlist.is_song_downloading).is_false() + + +func test_playing_a_song_tops_the_cache_up_again() -> void: + Playlist.number_of_tracks_to_preload = 3 + _quiz.song_loader.fetch_song() + + await _play_first_song() + + assert_int(Playlist.get_preloaded_songs()).is_equal(3) + assert_int(Playlist.song_object_array.size()).is_equal(4) + + +func test_cache_keeps_filling_after_a_failed_download() -> void: + _quiz.song_loader.retry_seconds = 0.05 + Playlist.number_of_tracks_to_preload = 3 + var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3") + # One more download works, then the server stops answering + _server.fail_after("/music/rand", 1) + + _quiz.song_loader.fetch_song() + await await_millis(150) + + assert_int(Playlist.get_preloaded_songs()).is_equal(2) + assert_bool(Playlist.is_song_downloading).is_true() + + _server.respond_bytes("/music/rand", mp3) + await await_millis(150) + + assert_int(Playlist.get_preloaded_songs()).is_equal(3) + assert_bool(Playlist.is_song_downloading).is_false() + + +func _open_settings() -> void: + _press("QuizToolbar/SettingsButton") + + +func _close_settings() -> void: + (_quiz.get_node("SettingsPopup") as Control).visible = false + + +func _press_settings_button(unique_name: String) -> void: + (_quiz.get_node("SettingsPopup").get_node("%" + unique_name) as Button).pressed.emit() + + +func test_raising_cache_size_does_not_download_while_settings_are_open() -> void: + var downloads_before: int = _server.count("/music/rand") + _open_settings() + + _press_settings_button("IncreaseCacheButton") + _press_settings_button("IncreaseCacheButton") + + assert_int(Playlist.number_of_tracks_to_preload).is_equal(3) + assert_int(_server.count("/music/rand")).is_equal(downloads_before) + + +func test_closing_settings_fills_cache_to_new_size() -> void: + _open_settings() + _press_settings_button("IncreaseCacheButton") + _press_settings_button("IncreaseCacheButton") + + _close_settings() + + assert_int(Playlist.get_preloaded_songs()).is_equal(3) + + +func test_changing_cache_size_back_and_forth_downloads_only_what_is_needed() -> void: + var downloads_before: int = _server.count("/music/rand") + _open_settings() + for i: int in 5: + _press_settings_button("IncreaseCacheButton") + _press_settings_button("LowerCacheButton") + _press_settings_button("IncreaseCacheButton") + + _close_settings() + + assert_int(_server.count("/music/rand") - downloads_before).is_equal(1) + assert_int(Playlist.get_preloaded_songs()).is_equal(2) + + +func test_closing_settings_with_full_cache_downloads_nothing() -> void: + var downloads_before: int = _server.count("/music/rand") + _open_settings() + + _close_settings() + + assert_int(_server.count("/music/rand")).is_equal(downloads_before) + + +func test_show_answer_before_first_song_does_not_reveal_next_song() -> void: + _press("SongControls/ShowAnswerButton") + + assert_bool(Playlist.song_object_array[0].is_answered).is_false() + assert_int(_server.count("/music/played?song=-1")).is_equal(0) + + await _play_first_song() + + assert_str((_quiz.get_node("NowPlaying/SoundtrackLabel") as Label).text) \ + .is_equal("????????") + + +func test_log_adds_one_row_per_point_with_padded_time() -> void: + var log_box: VBoxContainer = _window.get_node("%LogPopup").get_node("%LogVBoxContainer") + var crono: PlayerObject = _add_player("Crono") + await _play_first_song() + + crono.add_point() + crono.minus_point() + + assert_int(log_box.get_child_count()).is_equal(2) + var time_pattern: RegEx = RegEx.create_from_string("^\\d\\d:\\d\\d:\\d\\d Crono got a point$") + assert_object(time_pattern.search((log_box.get_child(0) as Label).text)).is_not_null() + assert_str((log_box.get_child(1) as Label).text).ends_with("Crono lost a point") diff --git a/tests/integration/test_main_window.gd.uid b/tests/integration/test_main_window.gd.uid new file mode 100644 index 0000000..2ae0d75 --- /dev/null +++ b/tests/integration/test_main_window.gd.uid @@ -0,0 +1 @@ +uid://c1a77blpbbckh diff --git a/tests/integration/test_main_window_startup.gd b/tests/integration/test_main_window_startup.gd new file mode 100644 index 0000000..cc03165 --- /dev/null +++ b/tests/integration/test_main_window_startup.gd @@ -0,0 +1,41 @@ +extends MainWindowTestSuite +## Startup with a slow /music/list response. + + +func _before_window_starts() -> void: + _server.respond_json("/music/list", [ + {"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time"}, + ]) + _next_song_is({"SongNo": 1, "Soundtrack": "Final Fantasy VI", "Song": "Dancing Mad"}) + _server.hold("/music/list") + + +func test_preloaded_song_survives_a_slow_song_list() -> void: + _server.release("/music/list") + + # The song already on the server, then the preloaded one + assert_int(Playlist.song_object_array.size()).is_equal(2) + assert_str(Playlist.song_object_array[1].song_title).is_equal("Dancing Mad") + assert_int(Playlist.get_preloaded_songs()).is_equal(1) + + +func test_next_plays_the_preloaded_song_after_a_slow_song_list() -> void: + _server.release("/music/list") + + await _play_first_song() + + assert_int(Playlist.currently_playing_song).is_equal(1) + + +func test_failed_song_list_is_retried_before_preloading() -> void: + _quiz.song_loader.retry_seconds = 0.05 + _server.respond_json("/music/list", [], 503) + _server.release("/music/list") + + assert_int(_server.count("/music/rand")).is_equal(0) + + _server.respond_json("/music/list", []) + await await_millis(200) + + assert_int(_server.count("/music/rand")).is_equal(1) + assert_int(Playlist.get_preloaded_songs()).is_equal(1) diff --git a/tests/integration/test_main_window_startup.gd.uid b/tests/integration/test_main_window_startup.gd.uid new file mode 100644 index 0000000..71b315e --- /dev/null +++ b/tests/integration/test_main_window_startup.gd.uid @@ -0,0 +1 @@ +uid://sfwrqidmuj04 diff --git a/tests/integration/test_music_list_panel.gd b/tests/integration/test_music_list_panel.gd new file mode 100644 index 0000000..73037f6 --- /dev/null +++ b/tests/integration/test_music_list_panel.gd @@ -0,0 +1,79 @@ +extends GdUnitTestSuite +## The music list on its own: one row per song in the playlist, and clicking a row +## tells which song was clicked. + +var _snapshot: AutoloadSnapshot +var _runner: GdUnitSceneRunner +var _panel: MusicListPanel + + +func before_test() -> void: + _snapshot = AutoloadSnapshot.take([Settings, Playlist]) + Playlist.reset_playlist() + for i: int in 3: + var song: SongObject = SongObject.new() + song.song_number = i + song.soundtrack_title = "Soundtrack %d" % i + song.song_title = "Song %d" % i + song.has_played = true + Playlist.add_to_playlist(song) + _runner = scene_runner("res://quiz/MusicListPanel.tscn") + _panel = _runner.scene() + + +func after_test() -> void: + await await_idle_frame() + _snapshot.restore() + + +func _rows() -> Array[Node]: + return _panel.get_node("ScrollContainer/MusicList").get_children() + + +func test_starts_empty() -> void: + assert_array(_rows()).is_empty() + + +func test_refresh_shows_one_row_per_song_in_order() -> void: + _panel.refresh() + + assert_array(_rows().map(func(row: SongRow) -> int: return row.song_object.song_number)) \ + .contains_exactly([0, 1, 2]) + + +func test_refresh_again_replaces_the_rows() -> void: + _panel.refresh() + Playlist.song_object_array.pop_back() + + _panel.refresh() + + assert_int(_rows().size()).is_equal(2) + + +func test_clicking_a_row_tells_which_song() -> void: + _panel.refresh() + await _runner.simulate_frames(2) + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel)) + var row: Control = _rows()[1] + # The left end of the row is the song text, not one of the player icons + var target: Vector2 = row.get_global_rect().position + Vector2(20, row.size.y / 2) + + _runner.set_mouse_position(_panel.get_viewport().get_final_transform() * target) + await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_LEFT) + await _runner.await_input_processed() + + await signals.is_emitted("song_clicked", [1]) + + +func test_right_click_does_nothing() -> void: + _panel.refresh() + await _runner.simulate_frames(2) + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel)) + var row: Control = _rows()[0] + + _runner.set_mouse_position(_panel.get_viewport().get_final_transform() + * (row.get_global_rect().position + Vector2(20, row.size.y / 2))) + await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_RIGHT) + await _runner.await_input_processed() + + await signals.wait_until(200).is_not_emitted("song_clicked") diff --git a/tests/integration/test_music_list_panel.gd.uid b/tests/integration/test_music_list_panel.gd.uid new file mode 100644 index 0000000..962ce7a --- /dev/null +++ b/tests/integration/test_music_list_panel.gd.uid @@ -0,0 +1 @@ +uid://c1dj75bnbdjle diff --git a/tests/integration/test_music_player_scene.gd b/tests/integration/test_music_player_scene.gd new file mode 100644 index 0000000..2080ab5 --- /dev/null +++ b/tests/integration/test_music_player_scene.gd @@ -0,0 +1,50 @@ +extends GdUnitTestSuite +## The music player scene: the player bar and the auto repeat switch under it. + +var _snapshot: AutoloadSnapshot +var _runner: GdUnitSceneRunner +var _player: MusicPlayer + + +func before_test() -> void: + _snapshot = AutoloadSnapshot.take([Settings]) + Settings.auto_repeat_song = true + _runner = scene_runner("res://player/MusicPlayer.tscn") + _player = _runner.scene() + + +func after_test() -> void: + _snapshot.restore() + + +func _click(control: Control) -> void: + _runner.set_mouse_position(_player.get_viewport().get_final_transform() + * control.get_global_rect().get_center()) + await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_LEFT) + await _runner.await_input_processed() + + +func test_auto_repeat_starts_on() -> void: + assert_bool(_player.auto_repeat_button.button_pressed).is_true() + + +func test_clicking_auto_repeat_turns_it_off_and_on() -> void: + await _click(_player.auto_repeat_button) + assert_bool(Settings.auto_repeat_song).is_false() + assert_bool(_player.auto_repeat_button.button_pressed).is_false() + + await _click(_player.auto_repeat_button) + assert_bool(Settings.auto_repeat_song).is_true() + + +func test_auto_repeat_is_under_the_player_bar() -> void: + await _runner.simulate_frames(1) + var bar: Rect2 = (_player.get_node("PlayerBar") as Control).get_global_rect() + var repeat: Rect2 = _player.auto_repeat_button.get_global_rect() + + assert_float(repeat.position.y).is_greater_equal(bar.end.y) + assert_bool(_player.get_global_rect().encloses(repeat)).is_true() + + +func test_empty_space_lets_clicks_through() -> void: + assert_int(_player.mouse_filter).is_equal(Control.MOUSE_FILTER_IGNORE) diff --git a/tests/integration/test_music_player_scene.gd.uid b/tests/integration/test_music_player_scene.gd.uid new file mode 100644 index 0000000..7f7c4a0 --- /dev/null +++ b/tests/integration/test_music_player_scene.gd.uid @@ -0,0 +1 @@ +uid://ck3ti57dgce3w diff --git a/tests/integration/test_now_playing.gd b/tests/integration/test_now_playing.gd new file mode 100644 index 0000000..f813dfd --- /dev/null +++ b/tests/integration/test_now_playing.gd @@ -0,0 +1,34 @@ +extends GdUnitTestSuite +## The soundtrack and song shown at the top of the quiz. + +var _runner: GdUnitSceneRunner +var _now_playing: NowPlaying + + +func before_test() -> void: + _runner = scene_runner("res://quiz/NowPlaying.tscn") + _now_playing = _runner.scene() + + +func _label(label_name: String) -> Label: + return _now_playing.get_node(label_name) + + +func test_starts_empty() -> void: + assert_str(_label("SoundtrackLabel").text).is_empty() + assert_str(_label("SongLabel").text).is_empty() + + +func test_show_song_shows_soundtrack_and_song() -> void: + _now_playing.show_song("Chrono Trigger", "Corridors of Time") + + assert_str(_label("SoundtrackLabel").text).is_equal("Chrono Trigger") + assert_str(_label("SongLabel").text).is_equal("Corridors of Time") + + +func test_soundtrack_is_above_song() -> void: + _now_playing.show_song("Chrono Trigger", "Corridors of Time") + await _runner.simulate_frames(1) + + assert_float(_label("SoundtrackLabel").get_global_rect().end.y) \ + .is_less_equal(_label("SongLabel").get_global_rect().position.y) diff --git a/tests/integration/test_now_playing.gd.uid b/tests/integration/test_now_playing.gd.uid new file mode 100644 index 0000000..fcd248d --- /dev/null +++ b/tests/integration/test_now_playing.gd.uid @@ -0,0 +1 @@ +uid://cidytlxgrmlk0 diff --git a/tests/integration/test_player_keybinds.gd b/tests/integration/test_player_keybinds.gd new file mode 100644 index 0000000..08ca816 --- /dev/null +++ b/tests/integration/test_player_keybinds.gd @@ -0,0 +1,152 @@ +extends MainWindowTestSuite +## Alt+ gives a point to the player in that row of the player list and +## Ctrl+Alt+ takes one away. These tests check that the keybinds keep +## following the rows while players are added and removed, and that points always +## end up with the right player. + +var _crono: PlayerObject +var _marle: PlayerObject +var _lucca: PlayerObject + + +func before_test() -> void: + super() + _crono = _add_player("Crono") + _marle = _add_player("Marle") + _lucca = _add_player("Lucca") + await _play_first_song() + + +func _song_points() -> Array[String]: + return Playlist.song_object_array[Playlist.currently_playing_song].players_given_point + + +func test_alt_number_gives_point_to_player_in_that_row() -> void: + await _give_point_with_keybind(2) + + assert_array(_scores()).contains_exactly([0, 1, 0]) + assert_array(_song_points()).contains_exactly([_marle.id]) + + +func test_ctrl_alt_number_takes_point_from_player_in_that_row() -> void: + await _give_point_with_keybind(3) + await _give_point_with_keybind(3) + + await _take_point_with_keybind(3) + + assert_array(_scores()).contains_exactly([0, 0, 1]) + + +func test_taking_last_point_removes_player_from_song() -> void: + await _give_point_with_keybind(1) + + await _take_point_with_keybind(1) + + assert_int(_crono.player_score).is_equal(0) + assert_array(_song_points()).is_empty() + + +func test_each_keybind_only_affects_its_own_player() -> void: + await _give_point_with_keybind(1) + await _give_point_with_keybind(3) + await _give_point_with_keybind(3) + + assert_int(_crono.player_score).is_equal(1) + assert_int(_marle.player_score).is_equal(0) + assert_int(_lucca.player_score).is_equal(2) + assert_array(_song_points()).contains_exactly([_crono.id, _lucca.id]) + + +func test_number_without_player_does_nothing() -> void: + await _give_point_with_keybind(4) + + assert_array(_scores()).contains_exactly([0, 0, 0]) + assert_array(_song_points()).is_empty() + + +func test_keybinds_move_up_when_first_player_is_removed() -> void: + await _remove_player(_crono) + + await _give_point_with_keybind(1) + await _give_point_with_keybind(2) + await _give_point_with_keybind(2) + + assert_int(_marle.player_score).is_equal(1) + assert_int(_lucca.player_score).is_equal(2) + assert_array(_song_points()).contains_exactly([_marle.id, _lucca.id]) + + +func test_keybind_after_last_row_does_nothing_after_removal() -> void: + await _remove_player(_marle) + + await _give_point_with_keybind(3) + + assert_int(_crono.player_score).is_equal(0) + assert_int(_lucca.player_score).is_equal(0) + + +func test_removing_player_removes_only_their_points() -> void: + await _give_point_with_keybind(1) + await _give_point_with_keybind(2) + await _give_point_with_keybind(3) + + await _remove_player(_marle) + + assert_array(_song_points()).contains_exactly([_crono.id, _lucca.id]) + assert_int(_crono.player_score).is_equal(1) + assert_int(_lucca.player_score).is_equal(1) + + +func test_new_player_gets_next_free_keybind_after_removal() -> void: + await _remove_player(_marle) + var robo: PlayerObject = _add_player("Robo") + + await _give_point_with_keybind(2) + await _give_point_with_keybind(3) + + assert_int(_lucca.player_score).is_equal(1) + assert_int(robo.player_score).is_equal(1) + assert_int(_crono.player_score).is_equal(0) + assert_array(_song_points()).contains_exactly([_lucca.id, robo.id]) + + +func test_new_player_does_not_inherit_removed_players_points() -> void: + await _give_point_with_keybind(3) + await _remove_player(_lucca) + + var robo: PlayerObject = _add_player("Robo") + + assert_int(robo.player_score).is_equal(0) + assert_array(_song_points()).is_empty() + + +func test_points_follow_players_through_several_changes() -> void: + await _give_point_with_keybind(1) + await _remove_player(_crono) + var robo: PlayerObject = _add_player("Robo") + await _give_point_with_keybind(1) + await _remove_player(_marle) + var ayla: PlayerObject = _add_player("Ayla") + await _give_point_with_keybind(3) + await _give_point_with_keybind(2) + await _take_point_with_keybind(2) + + # Rows are now: Lucca, Robo, Ayla + assert_array(Settings.player_array).contains_exactly([_lucca, robo, ayla]) + assert_int(_lucca.player_score).is_equal(0) + assert_int(robo.player_score).is_equal(0) + assert_int(ayla.player_score).is_equal(1) + assert_array(_song_points()).contains_exactly([ayla.id]) + + +func test_player_list_shows_rows_in_player_order() -> void: + await _remove_player(_crono) + var robo: PlayerObject = _add_player("Robo") + + var rows: Array[Node] = _quiz.get_node( + "PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer").get_children() + + assert_array(rows.map(func(row: PlayerRow) -> PlayerObject: return row.player_object)) \ + .contains_exactly([_marle, _lucca, robo]) + assert_array(rows.map(func(row: PlayerRow) -> int: return row.list_index)) \ + .contains_exactly([0, 1, 2]) diff --git a/tests/integration/test_player_keybinds.gd.uid b/tests/integration/test_player_keybinds.gd.uid new file mode 100644 index 0000000..3b55311 --- /dev/null +++ b/tests/integration/test_player_keybinds.gd.uid @@ -0,0 +1 @@ +uid://k3q6jrbpg3wa diff --git a/tests/integration/test_players_panel.gd b/tests/integration/test_players_panel.gd new file mode 100644 index 0000000..85afdc4 --- /dev/null +++ b/tests/integration/test_players_panel.gd @@ -0,0 +1,147 @@ +extends GdUnitTestSuite +## The player list on its own: adding players through the name field, removing them +## and asking for a new character. + +var _snapshot: AutoloadSnapshot +var _runner: GdUnitSceneRunner +var _panel: PlayersPanel + + +func before_test() -> void: + _snapshot = AutoloadSnapshot.take([Settings]) + Settings.player_array.clear() + Settings.edit_players = false + _runner = scene_runner("res://quiz/PlayersPanel.tscn") + _panel = _runner.scene() + + +func after_test() -> void: + await await_idle_frame() + _snapshot.restore() + + +func _rows() -> Array[Node]: + return _panel.players_list_container.get_children() + + +func _add(player_name: String) -> void: + _panel.new_player_name_field.text = player_name + _panel.add_player() + + +func _click(control: Control) -> void: + _runner.set_mouse_position(_panel.get_viewport().get_final_transform() + * control.get_global_rect().get_center()) + await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_LEFT) + await _runner.await_input_processed() + + +func test_add_button_shows_and_hides_the_add_row() -> void: + assert_bool(_panel.add_player_container.visible).is_false() + + await _click(_panel.add_players_button) + assert_bool(_panel.add_player_container.visible).is_true() + assert_bool(Settings.edit_players).is_true() + + await _click(_panel.add_players_button) + assert_bool(_panel.add_player_container.visible).is_false() + assert_bool(Settings.edit_players).is_false() + + +func test_adding_a_name_adds_a_player_and_a_row() -> void: + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel)) + + _add("Crono") + + assert_int(Settings.player_array.size()).is_equal(1) + var crono: PlayerObject = Settings.player_array[0] + assert_str(crono.player_name).is_equal("Crono") + await signals.is_emitted("player_added", [crono]) + assert_str(_panel.new_player_name_field.text).is_empty() + assert_object((_rows()[0] as PlayerRow).player_object).is_same(crono) + + +func test_add_button_in_the_row_adds_the_player() -> void: + await _click(_panel.add_players_button) + _panel.new_player_name_field.text = "Marle" + + await _click(_panel.add_player_button) + + assert_int(Settings.player_array.size()).is_equal(1) + + +func test_enter_in_the_name_field_adds_the_player() -> void: + await _click(_panel.add_players_button) + _panel.new_player_name_field.text = "Lucca" + _panel.new_player_name_field.grab_focus() + + _runner.simulate_key_pressed(KEY_ENTER) + await _runner.await_input_processed() + + assert_array(Settings.player_array.map( + func(p: PlayerObject) -> String: return p.player_name)).contains_exactly(["Lucca"]) + + +func test_empty_name_adds_nobody() -> void: + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel)) + + _add("") + + assert_array(Settings.player_array).is_empty() + assert_array(_rows()).is_empty() + await signals.wait_until(200).is_not_emitted("player_added") + + +func test_cannot_add_more_than_max_players() -> void: + for i: int in Settings.MAX_NUMBER_OF_PLAYERS + 1: + _add("Player %d" % i) + + assert_int(Settings.player_array.size()).is_equal(Settings.MAX_NUMBER_OF_PLAYERS) + assert_int(_rows().size()).is_equal(Settings.MAX_NUMBER_OF_PLAYERS) + + +func test_rows_follow_player_order_and_numbering() -> void: + _add("Crono") + _add("Marle") + _add("Lucca") + + assert_array(_rows().map( + func(row: PlayerRow) -> String: return row.player_object.player_name)) \ + .contains_exactly(["Crono", "Marle", "Lucca"]) + assert_array(_rows().map(func(row: PlayerRow) -> int: return row.list_index)) \ + .contains_exactly([0, 1, 2]) + + +func test_remove_button_removes_the_player_and_row() -> void: + _add("Crono") + _add("Marle") + var crono: PlayerObject = Settings.player_array[0] + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel)) + + (_rows()[0].get_node("HBoxContainer/RemovePlayer") as BaseButton).pressed.emit() + + await signals.is_emitted("player_removed", [crono]) + assert_array(Settings.player_array.map( + func(p: PlayerObject) -> String: return p.player_name)).contains_exactly(["Marle"]) + assert_int(_rows().size()).is_equal(1) + assert_int((_rows()[0] as PlayerRow).list_index).is_equal(0) + + +func test_character_button_asks_for_a_new_character() -> void: + _add("Crono") + var row: PlayerRow = _rows()[0] + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel)) + + (row.get_node("HBoxContainer/Character") as BaseButton).pressed.emit() + + await signals.is_emitted("change_character_requested", [row, "Crono"]) + + +func test_reload_shows_new_scores() -> void: + _add("Crono") + Settings.player_array[0].player_score = 3 + + _panel.reload() + + assert_str(((_rows()[0] as PlayerRow).get_node("HBoxContainer/Name") as Label).text) \ + .ends_with("Crono: 3") diff --git a/tests/integration/test_players_panel.gd.uid b/tests/integration/test_players_panel.gd.uid new file mode 100644 index 0000000..0e32cbb --- /dev/null +++ b/tests/integration/test_players_panel.gd.uid @@ -0,0 +1 @@ +uid://chtn6hbf4vqb6 diff --git a/tests/integration/test_popups.gd b/tests/integration/test_popups.gd new file mode 100644 index 0000000..54c0af8 --- /dev/null +++ b/tests/integration/test_popups.gd @@ -0,0 +1,131 @@ +extends MainWindowTestSuite +## All popups share BasePopup: clicking outside the panel closes them, the backdrop +## dims the window behind them, and the closing click does not reach the main window. +## The main window and the quiz each have their own popups and their own backdrop. + +## Popups in the main window: the ones opened from the top bar, and the log. +const MAIN_POPUPS: Array[String] = [ + "SearchPopup", "SyncPopup", "QRPopup", "ShortcutsPopup", "AboutPopup", "LogPopup", +] + +## Popups opened from the quiz, in the Quiz scene. +const QUIZ_POPUPS: Array[String] = [ + "SettingsPopup", "CharacterSelectPopup", "WinnerPopup", +] + + +func _popup(node_name: String) -> BasePopup: + return _quiz.get_node(node_name) if node_name in QUIZ_POPUPS \ + else _window.get_node(node_name) + + +## The backdrop that shows behind [param popup]. +func _backdrop(popup: BasePopup = _popup("SettingsPopup")) -> ColorRect: + return popup.get_parent().get_node("PopupBackdrop") + + +## A point on screen outside the popup's panel. +func _outside(popup: BasePopup) -> Vector2: + var rect: Rect2 = popup.panel.get_global_rect() + return Vector2(rect.end.x + 5, rect.end.y + 5) if rect.end.x < 1900 \ + else Vector2(rect.position.x - 5, rect.position.y - 5) + + +func test_backdrops_are_hidden_without_popups() -> void: + assert_bool(_backdrop(_popup("SettingsPopup")).visible).is_false() + assert_bool(_backdrop(_popup("AboutPopup")).visible).is_false() + + +func test_backdrops_cover_the_whole_window() -> void: + for node_name: String in ["SettingsPopup", "AboutPopup"]: + var popup: BasePopup = _popup(node_name) + popup.open() + + assert_object(_backdrop(popup).get_global_rect()).override_failure_message( + "%s: backdrop does not cover the window" % node_name) \ + .is_equal(_window.get_viewport_rect()) + popup.close() + + +func test_every_popup_is_a_popup_window() -> void: + for node_name: String in MAIN_POPUPS + QUIZ_POPUPS: + assert_object(_popup(node_name)).is_instanceof(BasePopup) + + +func test_every_popup_shows_backdrop_and_closes_on_outside_click() -> void: + for node_name: String in MAIN_POPUPS + QUIZ_POPUPS: + var popup: BasePopup = _popup(node_name) + popup.open() + await _runner.simulate_frames(1) + assert_bool(_backdrop(popup).visible).override_failure_message( + "%s: backdrop not shown" % node_name).is_true() + + await _click_at(_outside(popup)) + + assert_bool(popup.visible).override_failure_message( + "%s: not closed by outside click" % node_name).is_false() + assert_bool(_backdrop(popup).visible).override_failure_message( + "%s: backdrop still shown" % node_name).is_false() + + +func test_click_inside_popup_keeps_it_open() -> void: + var settings: BasePopup = _popup("SettingsPopup") + settings.open() + await _runner.simulate_frames(1) + + await _click_at(settings.panel.get_global_rect().get_center()) + + assert_bool(settings.visible).is_true() + + +func test_closing_click_does_not_press_button_behind_popup() -> void: + var settings: BasePopup = _popup("SettingsPopup") + var reset_button: Button = _quiz.get_node("QuizToolbar/ResetPlaylistButton") + settings.open() + await _runner.simulate_frames(1) + assert_bool(settings.panel.get_global_rect().intersects(reset_button.get_global_rect())) \ + .is_false() + + await _click_at(reset_button.get_global_rect().get_center()) + + assert_bool(settings.visible).is_false() + assert_int(_server.count("/music/reset")).is_equal(0) + + +func test_button_works_again_after_popup_closed() -> void: + var reset_button: Button = _quiz.get_node("QuizToolbar/ResetPlaylistButton") + + await _click_at(reset_button.get_global_rect().get_center()) + + assert_int(_server.count("/music/reset")).is_equal(1) + + +func test_closing_search_clears_the_search() -> void: + var search: BasePopup = _popup("SearchPopup") + search.open() + var search_bar: TextEdit = search.get_node("Searchbar") + search_bar.text = "mario" + + search.close() + + assert_str(search_bar.text).is_empty() + + +func test_closing_character_select_allows_song_clicks_again() -> void: + var character_select: BasePopup = _popup("CharacterSelectPopup") + character_select.show_grid("Crono") + assert_bool(Settings.character_select_open).is_true() + + character_select.close() + + assert_bool(Settings.character_select_open).is_false() + + +func test_toggle_opens_and_closes() -> void: + var shortcuts: BasePopup = _popup("ShortcutsPopup") + + shortcuts.toggle() + assert_bool(shortcuts.visible).is_true() + + shortcuts.toggle() + assert_bool(shortcuts.visible).is_false() diff --git a/tests/integration/test_popups.gd.uid b/tests/integration/test_popups.gd.uid new file mode 100644 index 0000000..8fc3dee --- /dev/null +++ b/tests/integration/test_popups.gd.uid @@ -0,0 +1 @@ +uid://kcnjdw4ke7hd diff --git a/tests/integration/test_quiz_toolbar.gd b/tests/integration/test_quiz_toolbar.gd new file mode 100644 index 0000000..8a9800f --- /dev/null +++ b/tests/integration/test_quiz_toolbar.gd @@ -0,0 +1,69 @@ +extends GdUnitTestSuite +## The row of quiz buttons: each button reports its press, and locking disables the +## buttons that use the song list. + +const BUTTON_SIGNALS: Dictionary = { + "ResetPlaylistButton": "reset_playlist_pressed", + "ResetPointsButton": "reset_points_pressed", + "SettingsButton": "settings_pressed", + "SoundTestButton": "sound_test_pressed", +} + +var _runner: GdUnitSceneRunner +var _toolbar: QuizToolbar + + +func before_test() -> void: + _runner = scene_runner("res://quiz/QuizToolbar.tscn") + _toolbar = _runner.scene() + + +func _button(button_name: String) -> Button: + return _toolbar.get_node(button_name) + + +func _click(button_name: String) -> void: + _runner.set_mouse_position(_toolbar.get_viewport().get_final_transform() + * _button(button_name).get_global_rect().get_center()) + await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_LEFT) + await _runner.await_input_processed() + + +func test_clicking_each_button_reports_it() -> void: + for button_name: String in BUTTON_SIGNALS: + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_toolbar)) + + await _click(button_name) + + await signals.override_failure_message(button_name + " not reported") \ + .is_emitted(BUTTON_SIGNALS[button_name]) + + +func test_buttons_are_in_order_left_to_right() -> void: + var lefts: Array = ["ResetPlaylistButton", "ResetPointsButton", "SettingsButton", + "SoundTestButton"].map(func(n: String) -> float: return _button(n).position.x) + + for i: int in lefts.size() - 1: + assert_float(lefts[i]).is_less(lefts[i + 1]) + + +func test_locking_disables_only_buttons_that_use_the_song_list() -> void: + _toolbar.set_locked(true) + + assert_bool(_button("ResetPlaylistButton").disabled).is_true() + assert_bool(_button("SoundTestButton").disabled).is_true() + assert_bool(_button("ResetPointsButton").disabled).is_false() + assert_bool(_button("SettingsButton").disabled).is_false() + + +func test_unlocking_enables_them_again() -> void: + _toolbar.set_locked(true) + + _toolbar.set_locked(false) + + assert_bool(_button("ResetPlaylistButton").disabled).is_false() + assert_bool(_button("SoundTestButton").disabled).is_false() + + +func test_clicks_between_buttons_go_through() -> void: + assert_int(_toolbar.mouse_filter).is_equal(Control.MOUSE_FILTER_IGNORE) diff --git a/tests/integration/test_quiz_toolbar.gd.uid b/tests/integration/test_quiz_toolbar.gd.uid new file mode 100644 index 0000000..2e37dc3 --- /dev/null +++ b/tests/integration/test_quiz_toolbar.gd.uid @@ -0,0 +1 @@ +uid://ecsdudhpw8oi diff --git a/tests/integration/test_scene_split.gd b/tests/integration/test_scene_split.gd new file mode 100644 index 0000000..7e42449 --- /dev/null +++ b/tests/integration/test_scene_split.gd @@ -0,0 +1,294 @@ +extends MainWindowTestSuite +## The main window holds the top bar, the quiz and the popups opened from the top bar. +## These tests check that the parts are where they belong and still work together: +## clicks reach the right scene, popups dim the whole window, and syncing, changing +## server and the keyboard shortcuts reach both the main window and the quiz. + +const MAIN_WINDOW_CHILDREN: Array[String] = [ + "Background", "TopBar", "Quiz", "PopupBackdrop", "SearchPopup", "SyncPopup", "QRPopup", + "ShortcutsPopup", "StatisticsPopupPanel", "AboutPopup", "LogPopup", +] + +## The parts of the quiz and the scene each one comes from. +const QUIZ_PART_SCENES: Dictionary = { + "MusicListPanel": "res://quiz/MusicListPanel.tscn", + "PlayersPanel": "res://quiz/PlayersPanel.tscn", + "InspirationList": "res://quiz/InspirationList.tscn", + "NowPlaying": "res://quiz/NowPlaying.tscn", + "QuizToolbar": "res://quiz/QuizToolbar.tscn", + "SongControls": "res://quiz/SongControls.tscn", + "MusicPlayer": "res://player/MusicPlayer.tscn", +} + +## The quiz buttons that are disabled while the server syncs. +const LOCKED_BY_SYNC: Array[String] = [ + "QuizToolbar/SoundTestButton", "QuizToolbar/ResetPlaylistButton", + "SongControls/ShowAnswerButton", "SongControls/NextButton", +] + +const TOP_BAR_CHILDREN: Array[String] = [ + "TopPanelContainer", "Title", "SyncButton", "SearchButton", "StatisticsButton", + "ShortcutsButton", "AboutButton", "QRButton", +] + +## Each top bar button and the popup it opens in the main window. +const TOP_BAR_POPUPS: Dictionary = { + "SearchButton": "SearchPopup", + "SyncButton": "SyncPopup", + "StatisticsButton": "StatisticsPopupPanel", + "ShortcutsButton": "ShortcutsPopup", + "AboutButton": "AboutPopup", + "QRButton": "QRPopup", +} + + +func _top_bar_button(button_name: String) -> Button: + return _window.get_node("TopBar/" + button_name) + + +func _children(node: Node) -> Array[String]: + var names: Array[String] = [] + for child: Node in node.get_children(): + names.append(String(child.name)) + return names + + +func _center(control: Control) -> Vector2: + return control.get_global_rect().get_center() + + +func test_main_window_holds_only_top_bar_quiz_and_its_popups() -> void: + assert_array(_children(_window)).contains_exactly_in_any_order(MAIN_WINDOW_CHILDREN) + + +func test_top_bar_is_drawn_below_the_quiz() -> void: + # The quiz popups' backdrop has to cover the top bar too + assert_int(_window.get_node("TopBar").get_index()) \ + .is_less(_window.get_node("Quiz").get_index()) + + +func test_quiz_parts_are_their_own_scenes() -> void: + for node_name: String in QUIZ_PART_SCENES: + assert_str(_quiz.get_node(node_name).scene_file_path) \ + .is_equal(QUIZ_PART_SCENES[node_name]) + + +func test_quiz_downloads_songs_with_a_song_loader() -> void: + assert_object(_quiz.song_loader).is_instanceof(SongLoader) + + +func test_auto_repeat_is_part_of_the_music_player() -> void: + assert_object(_quiz.get_node("MusicPlayer/RepeatSongCheckButton")).is_instanceof(CheckButton) + assert_object(_quiz.get_node_or_null("RepeatSongCheckButton")).is_null() + + +func test_top_bar_holds_only_title_and_buttons() -> void: + assert_array(_children(_window.get_node("TopBar"))) \ + .contains_exactly_in_any_order(TOP_BAR_CHILDREN) + + +func test_quiz_and_top_bar_let_clicks_through_where_they_are_empty() -> void: + assert_int(_quiz.mouse_filter).is_equal(Control.MOUSE_FILTER_IGNORE) + assert_int((_window.get_node("TopBar") as Control).mouse_filter) \ + .is_equal(Control.MOUSE_FILTER_IGNORE) + + +func test_clicking_each_top_bar_button_opens_its_popup() -> void: + for button_name: String in TOP_BAR_POPUPS: + var popup: Node = _window.get_node(TOP_BAR_POPUPS[button_name]) + + await _click_at(_center(_top_bar_button(button_name))) + + assert_bool(popup.visible).override_failure_message( + "%s did not open %s" % [button_name, popup.name]).is_true() + if popup is BasePopup: + popup.close() + else: + popup.visible = false + await _runner.simulate_frames(1) + + +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) \ + .is_not_equal("Chrono Trigger") + + await _click_at(_center(_quiz.get_node("SongControls/ShowAnswerButton"))) + + assert_str((_quiz.get_node("NowPlaying/SoundtrackLabel") as Label).text) \ + .is_equal("Chrono Trigger") + + +func test_clicking_a_song_in_the_music_list_plays_it() -> void: + _next_song_is(SONG_INFO[1]) + await _play_first_song() + _press("SongControls/NextButton") + # Changing song waits a moment for the old one to stop + await get_tree().create_timer(0.7).timeout + assert_int(Playlist.currently_playing_song).is_equal(1) + var rows: Array[Node] = _quiz.get_node("MusicListPanel/ScrollContainer/MusicList") \ + .get_children() + var first_row: Control = rows.filter( + func(row: SongRow) -> bool: return row.song_object.song_number == 0)[0] + + await _click_at(first_row.get_global_rect().position + Vector2(20, first_row.size.y / 2)) + await get_tree().create_timer(0.7).timeout + + assert_int(Playlist.currently_playing_song).is_equal(0) + + +func test_points_from_the_player_list_reach_the_song_and_log() -> void: + await _play_first_song() + var crono: PlayerObject = _add_player("Crono") + + (_player_row(crono).get_node("HBoxContainer/AddPoint") as BaseButton).pressed.emit() + + assert_array(Playlist.song_object_array[0].players_given_point).contains_exactly([crono.id]) + var log_rows: Node = _window.get_node("%LogPopup").get_node("%LogVBoxContainer") + assert_int(log_rows.get_child_count()) \ + .is_equal(1) + + +func test_character_button_in_the_player_list_picks_a_new_character() -> void: + var crono: PlayerObject = _add_player("Crono") + var character_select: BasePopup = _quiz.get_node("CharacterSelectPopup") + + (_player_row(crono).get_node("HBoxContainer/Character") as BaseButton).pressed.emit() + + assert_bool(character_select.visible).is_true() + var texture: ImageTexture = ImageTexture.create_from_image( + Image.create(4, 4, false, Image.FORMAT_RGBA8)) + character_select.character_selected.emit(texture) + assert_object(crono.character).is_same(texture) + + +func test_sound_test_button_fetches_the_sound_test_song() -> void: + _server.respond_bytes("/music/soundTest", + FileAccess.get_file_as_bytes("res://tests/data/short.mp3")) + + await _click_at(_center(_quiz.get_node("QuizToolbar/SoundTestButton"))) + + assert_int(_server.count("/music/soundTest")).is_equal(1) + + +func test_loaded_song_list_shows_in_the_music_list() -> void: + # No new song finishes downloading, so only loading the list can show the songs + _server.hold("/music/rand") + _server.respond_json("/music/list", [ + {"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time"}, + {"SongNo": 1, "Soundtrack": "Final Fantasy VI", "Song": "Dancing Mad"}, + ]) + Playlist.is_song_downloading = false + + _quiz.song_loader.load_song_list() + + var rows: Array[Node] = _quiz.get_node("MusicListPanel/ScrollContainer/MusicList") \ + .get_children() + assert_array(rows.map(func(row: SongRow) -> int: return row.song_object.song_number)) \ + .contains_exactly([0, 1]) + + +func test_quiz_popup_backdrop_covers_the_top_bar() -> void: + var settings: BasePopup = _quiz.get_node("SettingsPopup") + settings.open() + await _runner.simulate_frames(1) + + var backdrop: Control = _quiz.get_node("PopupBackdrop") + assert_bool(backdrop.visible).is_true() + assert_bool(backdrop.get_global_rect().encloses( + (_window.get_node("TopBar/TopPanelContainer") as Control).get_global_rect())) \ + .is_true() + + +func test_click_on_top_bar_while_quiz_popup_is_open_only_closes_it() -> void: + var settings: BasePopup = _quiz.get_node("SettingsPopup") + var search: BasePopup = _window.get_node("SearchPopup") + settings.open() + await _runner.simulate_frames(1) + + await _click_at(_center(_top_bar_button("SearchButton"))) + + assert_bool(settings.visible).is_false() + assert_bool(search.visible).is_false() + + +func test_sync_disables_quiz_buttons_until_finished() -> void: + _server.respond_bytes("/sync", PackedByteArray()) + _server.respond_json("/sync/progress", {"progress": 10, "time_spent": "00:00:01"}) + var sync_popup: BasePopup = _window.get_node("SyncPopup") + + _top_bar_button("SyncButton").pressed.emit() + sync_popup.sync_timer.stop() + + for button_name: String in LOCKED_BY_SYNC: + assert_bool((_quiz.get_node(button_name) as Button).disabled) \ + .override_failure_message(button_name + " not disabled").is_true() + assert_bool(_top_bar_button("SyncButton").disabled).is_false() + + _server.respond_json("/sync/progress", UiTour.SYNC_RESULT) + var reloads_before: int = _server.count("/music/all/order") + sync_popup._on_sync_timeout() + await _runner.simulate_frames(2) + + for button_name: String in LOCKED_BY_SYNC: + assert_bool((_quiz.get_node(button_name) as Button).disabled) \ + .override_failure_message(button_name + " still disabled").is_false() + # The search list in the main window and the quiz playlist are both reloaded + assert_int(_server.count("/music/all/order")).is_equal(reloads_before + 1) + assert_int(_server.count("/music/reset")).is_equal(1) + + +func test_changing_server_reloads_search_and_quiz() -> void: + var search_loads: int = _server.count("/music/all/order") + var inspiration_loads: int = _server.count("/music/all") + var player: PlayerObject = _add_player("Crono") + player.add_point() + + _quiz.get_node("SettingsPopup").server_changed.emit() + + assert_int(_server.count("/music/all/order")).is_equal(search_loads + 1) + assert_int(_server.count("/music/all")).is_equal(inspiration_loads + 1) + assert_int(_server.count("/music/reset")).is_equal(1) + assert_int(player.player_score).is_equal(0) + + +func test_alt_k_opens_shortcuts_and_alt_l_opens_log() -> void: + await _press_keys([KEY_ALT, KEY_K]) + assert_bool((_window.get_node("ShortcutsPopup") as Control).visible).is_true() + + await _press_keys([KEY_ALT, KEY_L]) + assert_bool((_window.get_node("LogPopup") as Control).visible).is_true() + + +func test_alt_down_lowers_the_volume() -> void: + var bus: int = AudioServer.get_bus_index("Master") + var volume_db: float = AudioServer.get_bus_volume_db(bus) + var slider: HSlider = _quiz.get_node("MusicPlayer").volume_slider + var volume: float = slider.value + + await _press_keys([KEY_ALT, KEY_DOWN]) + + assert_float(slider.value).is_less(volume) + AudioServer.set_bus_volume_db(bus, volume_db) + + +func test_alt_enter_switches_fullscreen_and_settings_shows_it() -> void: + var settings: BasePopup = _quiz.get_node("SettingsPopup") + var was_fullscreen: bool = Settings.fullscreen + + await _press_keys([KEY_ALT, KEY_ENTER]) + settings.open() + + assert_bool(Settings.fullscreen).is_equal(!was_fullscreen) + assert_bool(settings.fullscreen_button.button_pressed).is_equal(!was_fullscreen) + await _press_keys([KEY_ALT, KEY_ENTER]) + assert_bool(Settings.fullscreen).is_equal(was_fullscreen) + + +func test_statistics_counts_the_soundtracks() -> void: + _top_bar_button("StatisticsButton").pressed.emit() + + assert_str((_window.get_node("StatisticsPopupPanel/StatisticsLabel") as Label).text) \ + .is_equal("Total amount of soundtracks in the playlist: 2") diff --git a/tests/integration/test_scene_split.gd.uid b/tests/integration/test_scene_split.gd.uid new file mode 100644 index 0000000..1471506 --- /dev/null +++ b/tests/integration/test_scene_split.gd.uid @@ -0,0 +1 @@ +uid://c6lqhrfu02227 diff --git a/tests/integration/test_song_controls.gd b/tests/integration/test_song_controls.gd new file mode 100644 index 0000000..4eb8a15 --- /dev/null +++ b/tests/integration/test_song_controls.gd @@ -0,0 +1,85 @@ +extends GdUnitTestSuite +## Show answer and Next: each reports its press, also from its keyboard shortcut, and +## Next is only enabled while a song is ready. + +var _runner: GdUnitSceneRunner +var _controls: SongControls + + +func before_test() -> void: + _runner = scene_runner("res://quiz/SongControls.tscn") + _controls = _runner.scene() + + +func _click(button: Button) -> void: + _runner.set_mouse_position(_controls.get_viewport().get_final_transform() + * button.get_global_rect().get_center()) + await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_LEFT) + await _runner.await_input_processed() + + +func _press_keys(keys: Array[Key]) -> void: + for key: Key in keys: + _runner.simulate_key_press(key) + await _runner.await_input_processed() + keys.reverse() + for key: Key in keys: + _runner.simulate_key_release(key) + await _runner.await_input_processed() + + +func test_next_starts_disabled() -> void: + assert_bool(_controls.next_button.disabled).is_true() + + +func test_clicking_show_answer_reports_it() -> void: + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_controls)) + + await _click(_controls.show_answer_button) + + await signals.is_emitted("show_answer_pressed") + + +func test_clicking_next_reports_it_when_enabled() -> void: + _controls.set_next_enabled(true) + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_controls)) + + await _click(_controls.next_button) + + await signals.is_emitted("next_pressed") + + +func test_clicking_disabled_next_does_nothing() -> void: + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_controls)) + + await _click(_controls.next_button) + + await signals.wait_until(200).is_not_emitted("next_pressed") + + +func test_alt_v_shows_answer_and_alt_c_plays_next() -> void: + _controls.set_next_enabled(true) + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_controls)) + + await _press_keys([KEY_ALT, KEY_V]) + await signals.is_emitted("show_answer_pressed") + await _press_keys([KEY_ALT, KEY_C]) + await signals.is_emitted("next_pressed") + + +func test_locking_disables_both() -> void: + _controls.set_next_enabled(true) + + _controls.set_locked(true) + + assert_bool(_controls.show_answer_button.disabled).is_true() + assert_bool(_controls.next_button.disabled).is_true() + + +func test_unlocking_enables_show_answer_but_waits_for_a_song_for_next() -> void: + _controls.set_locked(true) + + _controls.set_locked(false) + + assert_bool(_controls.show_answer_button.disabled).is_false() + assert_bool(_controls.next_button.disabled).is_true() diff --git a/tests/integration/test_song_controls.gd.uid b/tests/integration/test_song_controls.gd.uid new file mode 100644 index 0000000..2c0c817 --- /dev/null +++ b/tests/integration/test_song_controls.gd.uid @@ -0,0 +1 @@ +uid://c43ii6v73reks diff --git a/tests/integration/test_song_loader.gd b/tests/integration/test_song_loader.gd new file mode 100644 index 0000000..9a4feb2 --- /dev/null +++ b/tests/integration/test_song_loader.gd @@ -0,0 +1,127 @@ +extends GdUnitTestSuite +## Downloading songs into the Playlist ahead of time, against a fake server. + +const SONG_INFO: Dictionary = { + "SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time", +} + +var _snapshot: AutoloadSnapshot +var _server: FakeHttpTransport +var _loader: SongLoader + + +func before_test() -> void: + _snapshot = AutoloadSnapshot.take([Settings, Playlist]) + Playlist.reset_playlist() + Playlist.is_song_downloading = false + Playlist.number_of_tracks_to_preload = 1 + Settings.use_low_played_mode = false + _server = FakeHttpTransport.new().install() + _server.respond_json("/music/list", []) + _server.respond_bytes("/music/rand", FileAccess.get_file_as_bytes("res://tests/data/short.mp3")) + _server.respond_bytes("/music/addQue", PackedByteArray()) + _server.respond_json("/music/info", SONG_INFO) + _loader = auto_free(SongLoader.new()) + add_child(_loader) + + +func after_test() -> void: + _server.uninstall() + _snapshot.restore() + + +func test_load_song_list_adds_songs_already_played_on_the_server() -> void: + _server.respond_json("/music/list", [ + {"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time"}, + ]) + _server.respond_json("/music/info", + {"SongNo": 1, "Soundtrack": "Final Fantasy VI", "Song": "Dancing Mad"}) + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_loader)) + + _loader.load_song_list() + + await signals.is_emitted("song_list_loaded") + var first: SongObject = Playlist.song_object_array[0] + assert_str(first.soundtrack_title).is_equal("Chrono Trigger") + assert_bool(first.has_played).is_true() + assert_bool(first.is_answered).is_true() + + +func test_load_song_list_then_preloads_a_new_song() -> void: + _loader.load_song_list() + + assert_int(_server.count("/music/rand")).is_equal(1) + assert_int(Playlist.song_object_array.size()).is_equal(1) + + +func test_fetch_song_downloads_queues_and_adds_the_song() -> void: + var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_loader)) + + _loader.fetch_song() + + await signals.is_emitted("song_added") + assert_array(_server.requested_paths) \ + .contains_exactly(["/music/rand", "/music/addQue", "/music/info"]) + var song: SongObject = Playlist.song_object_array[0] + assert_str(song.song_title).is_equal("Corridors of Time") + assert_object(song.song).is_not_null() + assert_bool(Playlist.is_song_downloading).is_false() + + +func test_fetch_song_keeps_going_until_the_cache_is_full() -> void: + Playlist.number_of_tracks_to_preload = 3 + + _loader.fetch_song() + + assert_int(Playlist.song_object_array.size()).is_equal(3) + assert_int(_server.count("/music/rand")).is_equal(3) + + +func test_low_played_mode_uses_its_endpoint() -> void: + Settings.use_low_played_mode = true + _server.respond_bytes("/music/rand/low", + FileAccess.get_file_as_bytes("res://tests/data/short.mp3")) + + _loader.fetch_song() + + assert_int(_server.count("/music/rand/low")).is_equal(1) + assert_int(_server.count("/music/rand")).is_equal(0) + + +func test_fetch_song_if_idle_does_nothing_while_downloading() -> void: + Playlist.is_song_downloading = true + + _loader.fetch_song_if_idle() + + assert_int(_server.count("/music/rand")).is_equal(0) + + +func test_fill_cache_does_nothing_when_the_cache_is_full() -> void: + _loader.fetch_song() + + _loader.fill_cache() + + assert_int(_server.count("/music/rand")).is_equal(1) + + +func test_fill_cache_downloads_after_the_cache_size_grows() -> void: + _loader.fetch_song() + Playlist.number_of_tracks_to_preload = 2 + + _loader.fill_cache() + + assert_int(_server.count("/music/rand")).is_equal(2) + + +func test_failed_step_is_retried_alone() -> void: + _loader.retry_seconds = 0.05 + _server.fail_after("/music/info", 0) + + _loader.fetch_song() + assert_int(Playlist.song_object_array.size()).is_equal(0) + _server.respond_json("/music/info", SONG_INFO) + await get_tree().create_timer(0.2).timeout + + assert_int(_server.count("/music/rand")).is_equal(1) + assert_int(_server.count("/music/addQue")).is_equal(1) + assert_int(Playlist.song_object_array.size()).is_equal(1) diff --git a/tests/integration/test_song_loader.gd.uid b/tests/integration/test_song_loader.gd.uid new file mode 100644 index 0000000..1826e9d --- /dev/null +++ b/tests/integration/test_song_loader.gd.uid @@ -0,0 +1 @@ +uid://caew6u3yi8m4n diff --git a/tests/integration/test_stats_after_win.gd b/tests/integration/test_stats_after_win.gd new file mode 100644 index 0000000..02cf849 --- /dev/null +++ b/tests/integration/test_stats_after_win.gd @@ -0,0 +1,65 @@ +extends MainWindowTestSuite +## Winning turns statistics off until a new game starts. + +var _crono: PlayerObject + + +func before_test() -> void: + super() + _crono = _add_player("Crono") + await _play_first_song() + Settings.winning_score = 1 + + +func _stats_checkbox() -> CheckButton: + return _quiz.get_node("SettingsPopup").get_node("%AddToDatabaseCheckButton") + + +func test_winning_turns_statistics_off() -> void: + await _give_point_with_keybind(1) + + assert_bool(Settings.add_to_stats).is_false() + + +func test_resetting_points_turns_statistics_back_on() -> void: + await _give_point_with_keybind(1) + + _press("QuizToolbar/ResetPointsButton") + + assert_bool(Settings.add_to_stats).is_true() + + +func test_statistics_turned_off_by_hand_stay_off_after_reset() -> void: + Settings.add_to_stats = false + await _give_point_with_keybind(1) + + _press("QuizToolbar/ResetPointsButton") + + assert_bool(Settings.add_to_stats).is_false() + + +func test_settings_checkbox_shows_statistics_off_after_win() -> void: + await _give_point_with_keybind(1) + + _press("QuizToolbar/SettingsButton") + + assert_bool(_stats_checkbox().button_pressed).is_false() + + +func test_songs_after_a_win_are_not_counted() -> void: + await _give_point_with_keybind(1) + var played_before: int = _server.count("/music/played?song=0") + + _press("SongControls/ShowAnswerButton") + + assert_int(_server.count("/music/played?song=0")).is_equal(played_before) + + +func test_winning_score_cannot_go_below_one() -> void: + _press("QuizToolbar/SettingsButton") + var lower_button: Button = _quiz.get_node("SettingsPopup").get_node("%LowerButton") + + for i: int in 3: + lower_button.pressed.emit() + + assert_int(Settings.winning_score).is_equal(1) diff --git a/tests/integration/test_stats_after_win.gd.uid b/tests/integration/test_stats_after_win.gd.uid new file mode 100644 index 0000000..793d2ef --- /dev/null +++ b/tests/integration/test_stats_after_win.gd.uid @@ -0,0 +1 @@ +uid://d20y2gtespbou diff --git a/tests/integration/test_sync_popup.gd b/tests/integration/test_sync_popup.gd new file mode 100644 index 0000000..ba8bb25 --- /dev/null +++ b/tests/integration/test_sync_popup.gd @@ -0,0 +1,201 @@ +extends GdUnitTestSuite +## The sync popup on its own: starting a sync, its progress, and how the result is shown. + +var _server: FakeHttpTransport +var _snapshot: AutoloadSnapshot +var _runner: GdUnitSceneRunner +var _window: Control + + +func before_test() -> void: + _snapshot = AutoloadSnapshot.take([Settings]) + Settings.currently_syncing = false + _server = FakeHttpTransport.new().install() + _server.respond_bytes("/sync", PackedByteArray()) + _runner = scene_runner("res://popups/SyncPopup.tscn") + _window = _runner.scene() + + +func after_test() -> void: + _server.uninstall() + _snapshot.restore() + + +func _node(unique_name: String) -> Control: + return _window.get_node("%" + unique_name) + + +func _finish_sync(result: Dictionary) -> void: + _server.respond_json("/sync/progress", result) + _runner.invoke("_on_sync_timeout") + + +func _empty_result() -> Dictionary: + return { + "soundtracks_added": [], + "soundtracks_re_added": [], + "soundtracks_changed_title": {}, + "soundtracks_changed_content": [], + "soundtracks_removed": [], + "catched_errors": [], + "total_time": "00:00:03", + } + + +func test_start_sync_asks_server_and_shows_progress() -> void: + monitor_signals(_window, false) + + _runner.invoke("start_sync") + + assert_array(_server.requested_paths).contains_exactly(["/sync"]) + assert_bool(Settings.currently_syncing).is_true() + assert_bool(_node("ProgressHBoxContainer").visible).is_true() + assert_str(_node("SyncTitleLabel").text).is_equal("Sync in Progress") + await assert_signal(_window).is_emitted("sync_started") + + +func test_start_sync_does_nothing_while_already_syncing() -> void: + Settings.currently_syncing = true + + _runner.invoke("start_sync") + + assert_array(_server.requested_paths).is_empty() + + +func test_progress_updates_progress_bar() -> void: + _runner.invoke("start_sync") + _server.respond_json("/sync/progress", {"progress": "42", "time_spent": "00:00:12"}) + + _runner.invoke("_on_sync_timeout") + + assert_float(_node("SyncProgressBar").value).is_equal(42.0) + assert_str(_node("SyncTime").text).is_equal("00:00:12") + + +func test_finished_sync_shows_only_sections_with_changes() -> void: + _runner.invoke("start_sync") + monitor_signals(_window, false) + var result: Dictionary = _empty_result() + result.soundtracks_added = ["Chrono Cross", "Xenogears"] + result.soundtracks_removed = ["Bad Rip"] + + _finish_sync(result) + + assert_bool(_node("SoundtracksAddedVBox").visible).is_true() + assert_bool(_node("SoundtracksRemovedVBox").visible).is_true() + assert_bool(_node("SoundtracksReAddedVBox").visible).is_false() + assert_bool(_node("SoundtracksChangedTitleVBox").visible).is_false() + assert_bool(_node("NoChangesLabel").visible).is_false() + # Header plus one label per soundtrack + assert_int(_node("SoundtracksAddedVBox").get_child_count()).is_equal(3) + assert_str(_node("SyncTime").text).is_equal("Total time: 00:00:03") + assert_bool(Settings.currently_syncing).is_false() + await assert_signal(_window).is_emitted("sync_finished") + + +func test_separator_shown_only_between_visible_sections() -> void: + _runner.invoke("start_sync") + var result: Dictionary = _empty_result() + result.soundtracks_added = ["Chrono Cross"] + result.soundtracks_removed = ["Bad Rip"] + + _finish_sync(result) + + assert_bool(_node("SoundtracksAddedSeparator").visible).is_true() + assert_bool(_node("SoundtracksRemovedSeparator").visible).is_false() + + +func test_changed_titles_list_old_and_new_title() -> void: + _runner.invoke("start_sync") + var result: Dictionary = _empty_result() + result.soundtracks_changed_title = {"FF7": "Final Fantasy VII"} + + _finish_sync(result) + + var vbox: Control = _node("SoundtracksChangedTitleVBox") + assert_bool(vbox.visible).is_true() + assert_str((vbox.get_child(1) as ListRow).text).is_equal( + "FF7\nchanged title to\nFinal Fantasy VII") + + +func test_no_changes_shows_no_changes_label() -> void: + _runner.invoke("start_sync") + + _finish_sync(_empty_result()) + + assert_bool(_node("NoChangesLabel").visible).is_true() + + +func test_errors_are_listed() -> void: + _runner.invoke("start_sync") + var result: Dictionary = _empty_result() + result.catched_errors = ["Could not read tags in broken.mp3"] + + _finish_sync(result) + + assert_bool(_node("CaughtErrorsVBox").visible).is_true() + + +func test_reopening_after_sync_shows_result_without_syncing_again() -> void: + _runner.invoke("start_sync") + _finish_sync(_empty_result()) + + _runner.invoke("start_sync") + + assert_int(_server.count("/sync")).is_equal(1) + assert_str(_node("SyncTitleLabel").text).is_equal("Sync Result") + + +func test_errors_section_has_errors_header() -> void: + _runner.invoke("start_sync") + var result: Dictionary = _empty_result() + result.catched_errors = ["Could not read tags in broken.mp3"] + + _finish_sync(result) + + assert_str((_node("CaughtErrorsVBox").get_child(0) as Label).text).is_equal("Errors") + + +func test_long_names_wrap_instead_of_widening_the_window() -> void: + _runner.invoke("start_sync") + var long_name: String = "The Legend of Zelda: Breath of the Wild Original Soundtrack" + var result: Dictionary = _empty_result() + result.soundtracks_added = [long_name] + result.soundtracks_re_added = [long_name] + result.soundtracks_changed_content = [long_name] + result.soundtracks_removed = [long_name] + result.soundtracks_changed_title = {long_name: long_name} + result.catched_errors = [long_name + " could not be read"] + + _finish_sync(result) + await _runner.simulate_frames(2) + + # The minimum width is what the content needs; wider than the panel it would stick out + var panel_width: float = (_window.get_node("SyncPanel") as Control).size.x + assert_float(_node("SyncHBoxContainer").get_combined_minimum_size().x) \ + .is_less(panel_width) + assert_float(_node("CaughtErrorsVBox").get_combined_minimum_size().x) \ + .is_less(panel_width) + + +func test_visible_columns_share_the_width_equally() -> void: + _runner.invoke("start_sync") + var result: Dictionary = _empty_result() + result.soundtracks_added = ["Chrono Cross", "Xenoblade Chronicles X Original Soundtrack"] + result.soundtracks_removed = ["Bad Rip"] + + _finish_sync(result) + await _runner.simulate_frames(2) + + assert_float(_node("SoundtracksAddedVBox").size.x).is_equal_approx( + _node("SoundtracksRemovedVBox").size.x, 1.0) + + +func test_column_headers() -> void: + _runner.invoke("start_sync") + var result: Dictionary = _empty_result() + result.soundtracks_re_added = ["Super Metroid"] + + _finish_sync(result) + + assert_str((_node("SoundtracksReAddedVBox").get_child(0) as Label).text).is_equal("Re-added") diff --git a/tests/integration/test_sync_popup.gd.uid b/tests/integration/test_sync_popup.gd.uid new file mode 100644 index 0000000..12f9798 --- /dev/null +++ b/tests/integration/test_sync_popup.gd.uid @@ -0,0 +1 @@ +uid://bvi8cyucyd8j4 diff --git a/tests/integration/test_theme.gd b/tests/integration/test_theme.gd new file mode 100644 index 0000000..c7044c8 --- /dev/null +++ b/tests/integration/test_theme.gd @@ -0,0 +1,117 @@ +extends GdUnitTestSuite +## The whole look comes from the project theme, themes/main_theme.tres: scenes and +## scripts name its styles instead of setting colours, sizes or style boxes themselves. + +const THEME_PATH: String = "res://themes/main_theme.tres" + +## Folders with the game's scenes and scripts. +const GAME_FOLDERS: Array[String] = [ + "res://main", "res://quiz", "res://player", "res://popups", "res://common", "res://data", + "res://server", +] + +## The named styles the game uses, and the type each one is based on. +const STYLES: Dictionary = { + "WindowBackground": "Panel", + "PopupBackground": "Panel", + "DarkSpacer": "Panel", + "CharacterCard": "Panel", + "MusicList": "PanelContainer", + "SongRow": "PanelContainer", + "ShortcutRowDark": "PanelContainer", + "ListRowEven": "PanelContainer", + "ListRowOdd": "PanelContainer", + "TitleLarge": "Label", + "TitleMedium": "Label", + "Title": "Label", + "TextLarge": "Label", + "TextMedium": "Label", + "HighlightLarge": "Label", + "HighlightMedium": "Label", + "SongRowLabel": "Label", + "SongRowLabelPlaying": "Label", +} + +var _theme: Theme = load(THEME_PATH) + + +## Every file in [param folder] and its subfolders whose name ends with [param extension]. +func _files(folder: String, extension: String) -> Array[String]: + var found: Array[String] = [] + for file_name: String in DirAccess.get_files_at(folder): + if file_name.ends_with(extension): + found.append(folder.path_join(file_name)) + for subfolder: String in DirAccess.get_directories_at(folder): + found.append_array(_files(folder.path_join(subfolder), extension)) + return found + + +func _game_files(extension: String) -> Array[String]: + var found: Array[String] = [] + for folder: String in GAME_FOLDERS: + found.append_array(_files(folder, extension)) + return found + + +func _lines_matching(files: Array[String], pattern: String) -> Array[String]: + var regex: RegEx = RegEx.create_from_string(pattern) + var hits: Array[String] = [] + for path: String in files: + var lines: PackedStringArray = FileAccess.get_file_as_string(path).split("\n") + for i: int in lines.size(): + if regex.search(lines[i]) != null: + hits.append("%s:%d: %s" % [path, i + 1, lines[i].strip_edges()]) + return hits + + +func test_project_uses_the_main_theme() -> void: + assert_str(ProjectSettings.get_setting("gui/theme/custom")).is_equal(THEME_PATH) + + +func test_theme_has_every_named_style() -> void: + for style: String in STYLES: + assert_str(String(_theme.get_type_variation_base(style))) \ + .override_failure_message(style + " is missing or has the wrong base") \ + .is_equal(STYLES[style]) + + +func test_theme_has_the_palette() -> void: + assert_bool(_theme.has_color("backdrop", "Palette")).is_true() + + +func test_every_style_named_in_scenes_and_scripts_exists() -> void: + var regex: RegEx = RegEx.create_from_string('&"([A-Z][A-Za-z]+)"') + var missing: Array[String] = [] + for path: String in _game_files(".tscn") + _game_files(".gd"): + for found: RegExMatch in regex.search_all(FileAccess.get_file_as_string(path)): + var style: String = found.get_string(1) + if !(style in STYLES) && style != "Palette": + missing.append("%s: %s" % [path, style]) + assert_array(missing).override_failure_message( + "Styles not in the theme:\n" + "\n".join(missing)).is_empty() + + +func test_scenes_take_their_look_from_the_theme() -> void: + var hits: Array[String] = _lines_matching(_game_files(".tscn"), + "^(theme = |label_settings = |theme_override_(styles|colors|font_sizes|fonts)/)" \ + + "|^\\[sub_resource type=\"(StyleBoxFlat|LabelSettings|Theme)\"") + assert_array(hits).override_failure_message( + "Look set in a scene instead of the theme:\n" + "\n".join(hits)).is_empty() + + +func test_scripts_take_their_look_from_the_theme() -> void: + var hits: Array[String] = _lines_matching(_game_files(".gd"), + "add_theme_[a-z_]+_override|StyleBoxFlat\\.new|Color\\(") + assert_array(hits).override_failure_message( + "Look set in a script instead of the theme:\n" + "\n".join(hits)).is_empty() + + +func test_main_window_background_comes_from_the_theme_and_lets_clicks_through() -> void: + var main: Control = auto_free(load("res://main/MainWindow.tscn").instantiate()) + var background: Panel = main.get_child(0) + + assert_str(background.name).is_equal("Background") + assert_str(String(background.theme_type_variation)).is_equal("WindowBackground") + assert_int(background.mouse_filter).is_equal(Control.MOUSE_FILTER_IGNORE) + assert_float(background.anchor_right).is_equal(1.0) + assert_float(background.anchor_bottom).is_equal(1.0) diff --git a/tests/integration/test_theme.gd.uid b/tests/integration/test_theme.gd.uid new file mode 100644 index 0000000..035b765 --- /dev/null +++ b/tests/integration/test_theme.gd.uid @@ -0,0 +1 @@ +uid://b2wva7kaaq8ua diff --git a/tests/unit/test_game_log.gd b/tests/unit/test_game_log.gd new file mode 100644 index 0000000..975b874 --- /dev/null +++ b/tests/unit/test_game_log.gd @@ -0,0 +1,23 @@ +extends GdUnitTestSuite +## GameLog joins its arguments like print() does. + + +func test_joins_parts_without_spaces_like_print() -> void: + assert_str(GameLog._join(["Song ", 3, " of ", 10.5])).is_equal("Song 3 of 10.5") + + +func test_joins_any_kind_of_value() -> void: + assert_str(GameLog._join([null, true, [1, 2], {"a": 1}])) \ + .is_equal("true[1, 2]{ \"a\": 1 }") + + +func test_no_parts_gives_an_empty_line() -> void: + assert_str(GameLog._join([])).is_empty() + + +func test_debug_takes_any_number_of_arguments() -> void: + GameLog.debug() + GameLog.debug("one") + GameLog.debug("one", 2, Vector2.ONE) + # Reaching this line means none of the calls failed + assert_bool(true).is_true() diff --git a/tests/unit/test_game_log.gd.uid b/tests/unit/test_game_log.gd.uid new file mode 100644 index 0000000..335c06d --- /dev/null +++ b/tests/unit/test_game_log.gd.uid @@ -0,0 +1 @@ +uid://delnqk8yt0hes diff --git a/tests/unit/test_http_transport.gd b/tests/unit/test_http_transport.gd new file mode 100644 index 0000000..0a31f82 --- /dev/null +++ b/tests/unit/test_http_transport.gd @@ -0,0 +1,48 @@ +extends GdUnitTestSuite +## Uses a local TCP server that accepts connections but never answers, like a hung +## MusicServer. + +var _server: TCPServer +var _connections: Array[StreamPeerTCP] = [] +var _owner: Node + + +func before_test() -> void: + _server = TCPServer.new() + assert_int(_server.listen(0, "127.0.0.1")).is_equal(OK) + _owner = auto_free(Node.new()) + add_child(_owner) + + +func after_test() -> void: + for connection: StreamPeerTCP in _connections: + connection.disconnect_from_host() + _server.stop() + + +func _accept_connections() -> void: + while _server.is_connection_available(): + _connections.append(_server.take_connection()) + + +func test_request_to_silent_server_times_out_as_failed() -> void: + var transport: HttpTransport = HttpTransport.new(_owner) + transport.timeout_seconds = 0.5 + var response_codes: Array[int] = [] + + transport.request("http://127.0.0.1:%d/music/rand" % _server.get_local_port(), + HTTPClient.METHOD_GET, + func(response_code: int, _body: PackedByteArray) -> void: + response_codes.append(response_code)) + for i: int in 40: + _accept_connections() + await await_millis(50) + if !response_codes.is_empty(): + break + + assert_bool(_connections.is_empty()).is_false() + assert_array(response_codes).contains_exactly([0]) + + +func test_default_timeout_is_set() -> void: + assert_float(HttpTransport.new(_owner).timeout_seconds).is_greater(0.0) diff --git a/tests/unit/test_http_transport.gd.uid b/tests/unit/test_http_transport.gd.uid new file mode 100644 index 0000000..d3c0ab7 --- /dev/null +++ b/tests/unit/test_http_transport.gd.uid @@ -0,0 +1 @@ +uid://3ubc73vxxqir diff --git a/tests/unit/test_list_row.gd b/tests/unit/test_list_row.gd new file mode 100644 index 0000000..5e277fe --- /dev/null +++ b/tests/unit/test_list_row.gd @@ -0,0 +1,34 @@ +extends GdUnitTestSuite +## ListRow: text, alternating backgrounds and wrapping. + + +func _background(list_row: ListRow) -> Color: + return (list_row.get_theme_stylebox("panel") as StyleBoxFlat).bg_color + + +## Adds [param list_row] to the tree, where it gets its look from the project theme. +func _shown(list_row: ListRow) -> ListRow: + add_child(auto_free(list_row)) + return list_row + + +func test_row_shows_text() -> void: + var list_row: ListRow = auto_free(ListRow.create("Chrono Trigger", 0, &"TextLarge")) + + assert_str(list_row.text).is_equal("Chrono Trigger") + + +func test_neighbouring_rows_have_different_backgrounds() -> void: + var first: ListRow = _shown(ListRow.create("Chrono Trigger", 0, &"TextLarge")) + var second: ListRow = _shown(ListRow.create("Chrono Cross", 1, &"TextLarge")) + var third: ListRow = _shown(ListRow.create("Xenogears", 2, &"TextLarge")) + + assert_that(_background(first)).is_not_equal(_background(second)) + assert_that(_background(first)).is_equal(_background(third)) + + +func test_long_text_wraps() -> void: + var list_row: ListRow = auto_free( + ListRow.create("Castlevania: Symphony of the Night", 0, &"TextLarge")) + + assert_int(list_row.label.autowrap_mode).is_equal(TextServer.AUTOWRAP_WORD_SMART) diff --git a/tests/unit/test_list_row.gd.uid b/tests/unit/test_list_row.gd.uid new file mode 100644 index 0000000..70b4210 --- /dev/null +++ b/tests/unit/test_list_row.gd.uid @@ -0,0 +1 @@ +uid://bouligcywd5se diff --git a/tests/unit/test_music_player_time.gd b/tests/unit/test_music_player_time.gd new file mode 100644 index 0000000..cd8e938 --- /dev/null +++ b/tests/unit/test_music_player_time.gd @@ -0,0 +1,69 @@ +extends GdUnitTestSuite +## How the music player shows a song's time, including the hidden parts. + +var _music_player: MusicPlayer +var _hide_beginning: bool +var _hide_length: bool +var _hide_for_seconds: float + + +func before_test() -> void: + _music_player = auto_free(MusicPlayer.new()) + _hide_beginning = Settings.hide_beginning + _hide_length = Settings.hide_length + _hide_for_seconds = Settings.hide_for_seconds + Settings.hide_beginning = false + Settings.hide_length = false + Settings.hide_for_seconds = 5.0 + + +func after_test() -> void: + Settings.hide_beginning = _hide_beginning + Settings.hide_length = _hide_length + Settings.hide_for_seconds = _hide_for_seconds + + +func test_format_time() -> void: + assert_str(_music_player.format_time(0.0)).is_equal("00:00") + assert_str(_music_player.format_time(65.0)).is_equal("01:05") + assert_str(_music_player.format_time(3661.0)).is_equal("61:01") + + +func test_format_time_rounds_up_into_next_minute() -> void: + assert_str(_music_player.format_time(59.6)).is_equal("01:00") + + +func test_format_text_shows_position_and_length() -> void: + assert_str(_music_player.format_text(10.0, 100.0)).is_equal("00:10 / 01:40") + + +func test_format_text_hides_length() -> void: + Settings.hide_length = true + + assert_str(_music_player.format_text(10.0, 100.0)).is_equal("00:10 / ??:??") + + +func test_format_text_hides_position_at_start() -> void: + Settings.hide_beginning = true + + assert_str(_music_player.format_text(3.0, 100.0)).is_equal("??:?? / 01:40") + + +func test_format_text_shows_position_after_hidden_start() -> void: + Settings.hide_beginning = true + + assert_str(_music_player.format_text(5.0, 100.0)).is_equal("00:05 / 01:40") + + +func test_format_text_hides_everything_at_start() -> void: + Settings.hide_beginning = true + Settings.hide_length = true + + assert_str(_music_player.format_text(3.0, 100.0)).is_equal("??:?? / ??:??") + + +func test_format_text_keeps_length_hidden_after_hidden_start() -> void: + Settings.hide_beginning = true + Settings.hide_length = true + + assert_str(_music_player.format_text(5.0, 100.0)).is_equal("00:05 / ??:??") diff --git a/tests/unit/test_music_player_time.gd.uid b/tests/unit/test_music_player_time.gd.uid new file mode 100644 index 0000000..a1fe579 --- /dev/null +++ b/tests/unit/test_music_player_time.gd.uid @@ -0,0 +1 @@ +uid://xoskre03iorg diff --git a/tests/unit/test_music_server_client.gd b/tests/unit/test_music_server_client.gd new file mode 100644 index 0000000..d4e8e92 --- /dev/null +++ b/tests/unit/test_music_server_client.gd @@ -0,0 +1,148 @@ +extends GdUnitTestSuite +## MusicServerClient: which paths it asks for and how it handles the answers. + +var _server: FakeHttpTransport + + +func before_test() -> void: + _server = FakeHttpTransport.new().install() + + +func after_test() -> void: + _server.uninstall() + + +func test_fetch_song_list_passes_parsed_json() -> void: + var songs: Array = [{"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time"}] + _server.respond_json("/music/list", songs) + var received: Array = [] + + MusicServerClient.fetch_song_list(func(data: Array) -> void: received.append_array(data)) + + assert_int(received.size()).is_equal(1) + assert_int(int(received[0].SongNo)).is_equal(0) + assert_str(received[0].Soundtrack).is_equal("Chrono Trigger") + + +func test_fetch_random_song_uses_normal_endpoint() -> void: + MusicServerClient.fetch_random_song(false, func(_body: PackedByteArray) -> void: pass) + + assert_array(_server.requested_paths).contains_exactly(["/music/rand"]) + + +func test_fetch_random_song_uses_low_played_endpoint() -> void: + MusicServerClient.fetch_random_song(true, func(_body: PackedByteArray) -> void: pass) + + assert_array(_server.requested_paths).contains_exactly(["/music/rand/low"]) + + +func test_fetch_song_passes_raw_bytes() -> void: + var mp3: PackedByteArray = PackedByteArray([0x49, 0x44, 0x33]) + _server.respond_bytes("/music?song=4", mp3) + var received: Array[PackedByteArray] = [] + + MusicServerClient.fetch_song(4, func(body: PackedByteArray) -> void: received.append(body)) + + assert_array(received).contains_exactly([mp3]) + + +func test_failed_request_does_not_call_back() -> void: + _server.respond_json("/music/list", [], 500) + var called: Array[bool] = [false] + + MusicServerClient.fetch_song_list(func(_data: Variant) -> void: called[0] = true) + + assert_bool(called[0]).is_false() + + +func test_invalid_json_does_not_call_back() -> void: + _server.respond_bytes("/music/info", "not json".to_utf8_buffer()) + var called: Array[bool] = [false] + + MusicServerClient.fetch_song_info(func(_data: Variant) -> void: called[0] = true) + + assert_bool(called[0]).is_false() + + +func test_failed_request_calls_on_failed() -> void: + _server.respond_bytes("/music/rand", PackedByteArray(), 503) + var outcome: Array[String] = [] + + MusicServerClient.fetch_random_song(false, + func(_body: PackedByteArray) -> void: outcome.append("done"), + func() -> void: outcome.append("failed")) + + assert_array(outcome).contains_exactly(["failed"]) + + +func test_invalid_json_calls_on_failed() -> void: + _server.respond_bytes("/music/info", "not json".to_utf8_buffer()) + var outcome: Array[String] = [] + + MusicServerClient.fetch_song_info( + func(_data: Variant) -> void: outcome.append("done"), + func() -> void: outcome.append("failed")) + + assert_array(outcome).contains_exactly(["failed"]) + + +func test_successful_request_does_not_call_on_failed() -> void: + _server.respond_bytes("/music/addQue", PackedByteArray()) + var outcome: Array[String] = [] + + MusicServerClient.add_to_queue( + func() -> void: outcome.append("done"), + func() -> void: outcome.append("failed")) + + assert_array(outcome).contains_exactly(["done"]) + + +func test_send_calls_back_without_arguments() -> void: + _server.respond_bytes("/music/reset", PackedByteArray()) + var called: Array[bool] = [false] + + MusicServerClient.reset_playlist(func() -> void: called[0] = true) + + assert_bool(called[0]).is_true() + + +func test_mark_song_played_sends_put() -> void: + _server.respond_bytes("/music/played?song=3", PackedByteArray()) + + MusicServerClient.mark_song_played(3) + + assert_array(_server.requested_paths).contains_exactly(["/music/played?song=3"]) + assert_array(_server.requested_methods).contains_exactly([HTTPClient.METHOD_PUT]) + + +func test_character_name_is_url_encoded() -> void: + MusicServerClient.fetch_character_image("Paper Mario.png", + func(_body: PackedByteArray) -> void: pass) + + assert_array(_server.requested_paths).contains_exactly(["/character?name=Paper%20Mario.png"]) + + +func test_requests_go_to_selected_server() -> void: + var previous_path: String = Settings.default_path + Settings.default_path = "http://localhost:8080" + var requested_urls: Array[String] = [] + var recording_transport: HttpTransport = _RecordingTransport.new(requested_urls) + MusicServerClient.transport = recording_transport + + MusicServerClient.fetch_song_info(func(_data: Variant) -> void: pass) + + Settings.default_path = previous_path + assert_array(requested_urls).contains_exactly(["http://localhost:8080/music/info"]) + + +class _RecordingTransport: + extends HttpTransport + + var _urls: Array[String] + + func _init(urls: Array[String]) -> void: + super(null) + _urls = urls + + func request(url: String, _method: int, _on_complete: Callable) -> void: + _urls.append(url) diff --git a/tests/unit/test_music_server_client.gd.uid b/tests/unit/test_music_server_client.gd.uid new file mode 100644 index 0000000..f6fedd6 --- /dev/null +++ b/tests/unit/test_music_server_client.gd.uid @@ -0,0 +1 @@ +uid://3grferwg0j7g diff --git a/tests/unit/test_player_object.gd b/tests/unit/test_player_object.gd new file mode 100644 index 0000000..524e735 --- /dev/null +++ b/tests/unit/test_player_object.gd @@ -0,0 +1,139 @@ +extends GdUnitTestSuite +## PlayerObject: points, and the first point, match point and winner signals. + +var _winning_score: int +var _player: PlayerObject + + +func before_test() -> void: + _winning_score = Settings.winning_score + Settings.winning_score = 3 + _player = PlayerObject.new("Crono") + + +func after_test() -> void: + Settings.winning_score = _winning_score + + +func test_first_point_triggers_first_point_signal() -> void: + monitor_signals(_player, false) + + _player.add_point() + + assert_int(_player.player_score).is_equal(1) + await assert_signal(_player).is_emitted("first_point_triggered") + await assert_signal(_player).is_emitted("player_point_given") + + +func test_one_point_from_winning_triggers_match_point() -> void: + _player.add_point() + monitor_signals(_player, false) + + _player.add_point() + + await assert_signal(_player).is_emitted("match_point_triggered") + + +func test_reaching_winning_score_triggers_winner() -> void: + _player.add_point() + _player.add_point() + monitor_signals(_player, false) + + _player.add_point() + + assert_int(_player.player_score).is_equal(3) + await assert_signal(_player).is_emitted("winner_triggered") + + +func test_score_does_not_go_past_winning_score() -> void: + for i: int in 3: + _player.add_point() + monitor_signals(_player, false) + + _player.add_point() + + assert_int(_player.player_score).is_equal(3) + await assert_signal(_player).wait_until(50).is_not_emitted("player_point_given") + + +func test_ordinary_point_plays_point_sound() -> void: + Settings.winning_score = 10 + _player.add_point() + monitor_signals(_player, false) + + _player.add_point() + + await assert_signal(_player).is_emitted("point_given_sound") + + +func test_minus_point_lowers_score() -> void: + _player.add_point() + monitor_signals(_player, false) + + _player.minus_point() + + assert_int(_player.player_score).is_equal(0) + await assert_signal(_player).is_emitted("player_point_taken") + + +func test_score_does_not_go_below_zero() -> void: + monitor_signals(_player, false) + + _player.minus_point() + + assert_int(_player.player_score).is_equal(0) + await assert_signal(_player).wait_until(50).is_not_emitted("player_point_taken") + + +func test_dropping_back_to_zero_allows_first_point_again() -> void: + _player.add_point() + _player.minus_point() + monitor_signals(_player, false) + + _player.add_point() + + await assert_signal(_player).is_emitted("first_point_triggered") + + +func test_reset_points() -> void: + _player.add_point() + _player.add_point() + + _player.reset_points() + + assert_int(_player.player_score).is_equal(0) + assert_bool(_player.is_first_point).is_true() + + +func test_id_is_a_uuid() -> void: + var uuid_pattern: RegEx = RegEx.create_from_string( + "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + + assert_object(uuid_pattern.search(_player.id)).is_not_null() + + +func test_every_player_gets_a_different_id() -> void: + var ids: Dictionary = {} + for i: int in 100: + ids[PlayerObject.new("Player %d" % i).id] = true + + assert_int(ids.size()).is_equal(100) + + +func test_winning_score_of_one_is_won_with_first_point() -> void: + Settings.winning_score = 1 + monitor_signals(_player, false) + + _player.add_point() + + await assert_signal(_player).is_emitted("winner_triggered") + + +func test_first_point_is_match_point_when_winning_score_is_two() -> void: + Settings.winning_score = 2 + monitor_signals(_player, false) + + _player.add_point() + + await assert_signal(_player).is_emitted("match_point_triggered") + assert_bool(_player.is_first_point).is_false() diff --git a/tests/unit/test_player_object.gd.uid b/tests/unit/test_player_object.gd.uid new file mode 100644 index 0000000..f897acb --- /dev/null +++ b/tests/unit/test_player_object.gd.uid @@ -0,0 +1 @@ +uid://cq4vprpa1epya diff --git a/tests/unit/test_playlist.gd b/tests/unit/test_playlist.gd new file mode 100644 index 0000000..60fbd70 --- /dev/null +++ b/tests/unit/test_playlist.gd @@ -0,0 +1,202 @@ +extends GdUnitTestSuite +## Playlist: moving through the songs, answering them and keeping points. + +var _server: FakeHttpTransport +var _add_to_stats: bool +var _is_debug: bool + + +func before_test() -> void: + _server = FakeHttpTransport.new().install() + _add_to_stats = Settings.add_to_stats + _is_debug = Settings.is_debug + Settings.add_to_stats = false + Settings.is_debug = false + Playlist.reset_playlist() + Playlist.number_of_tracks_to_preload = 1 + + +func after_test() -> void: + Playlist.reset_playlist() + Settings.add_to_stats = _add_to_stats + Settings.is_debug = _is_debug + _server.uninstall() + + +func _add_songs(count: int) -> Array[SongObject]: + var songs: Array[SongObject] = [] + for i: int in count: + var song: SongObject = SongObject.new() + song.song_number = i + song.soundtrack_title = "Soundtrack %d" % i + song.song_title = "Song %d" % i + Playlist.add_to_playlist(song) + songs.append(song) + return songs + + +func test_set_latest_played_song_advances() -> void: + _add_songs(3) + + Playlist.set_latest_played_song() + + assert_int(Playlist.latest_played_song).is_equal(1) + + +func test_set_latest_played_song_stops_at_last_song() -> void: + _add_songs(2) + + Playlist.set_latest_played_song() + Playlist.set_latest_played_song() + Playlist.set_latest_played_song() + + assert_int(Playlist.latest_played_song).is_equal(1) + + +func test_get_next_song_returns_latest_played() -> void: + var songs: Array[SongObject] = _add_songs(3) + Playlist.latest_played_song = 2 + + assert_object(Playlist.get_next_song()).is_same(songs[2]) + + +func test_update_last_played_song_points_past_existing_songs() -> void: + _add_songs(4) + + Playlist.update_last_played_song() + + assert_int(Playlist.latest_played_song).is_equal(4) + + +func test_preloaded_songs_counts_unplayed_songs() -> void: + var songs: Array[SongObject] = _add_songs(3) + songs[0].has_played = true + + assert_int(Playlist.get_preloaded_songs()).is_equal(2) + + +func test_should_preload_more_songs_when_below_target() -> void: + var songs: Array[SongObject] = _add_songs(1) + songs[0].has_played = true + + assert_bool(Playlist.should_preload_more_songs()).is_true() + + +func test_should_not_preload_more_songs_when_target_reached() -> void: + _add_songs(1) + + assert_bool(Playlist.should_preload_more_songs()).is_false() + + +func test_get_playlist_hides_unplayed_songs() -> void: + var songs: Array[SongObject] = _add_songs(3) + songs[0].has_played = true + + assert_array(Playlist.get_playlist()).contains_exactly([songs[0]]) + + +func test_get_playlist_shows_everything_in_debug() -> void: + Settings.is_debug = true + var songs: Array[SongObject] = _add_songs(2) + + assert_array(Playlist.get_playlist()).contains_exactly(songs) + + +func test_song_is_answered_marks_song() -> void: + var songs: Array[SongObject] = _add_songs(2) + + Playlist.song_is_answered(1) + + assert_bool(songs[1].is_answered).is_true() + assert_bool(songs[0].is_answered).is_false() + + +func test_song_is_answered_reports_to_stats_when_enabled() -> void: + Settings.add_to_stats = true + _add_songs(2) + + Playlist.song_is_answered(1) + + assert_array(_server.requested_paths).contains_exactly(["/music/played?song=1"]) + + +func test_song_is_answered_does_not_report_when_stats_disabled() -> void: + _add_songs(2) + + Playlist.song_is_answered(1) + + assert_array(_server.requested_paths).is_empty() + + +func test_points_are_recorded_on_current_song() -> void: + var songs: Array[SongObject] = _add_songs(2) + Playlist.set_currently_playing_song(1) + + Playlist.add_point("lucca-id") + Playlist.add_point("crono-id") + + assert_array(songs[1].players_given_point).contains_exactly(["lucca-id", "crono-id"]) + assert_array(songs[0].players_given_point).is_empty() + + +func test_remove_point_from_current_song() -> void: + var songs: Array[SongObject] = _add_songs(1) + Playlist.set_currently_playing_song(0) + Playlist.add_point("crono-id") + Playlist.add_point("marle-id") + + Playlist.remove_point("crono-id") + + assert_array(songs[0].players_given_point).contains_exactly(["marle-id"]) + + +func test_remove_player_removes_only_that_players_points() -> void: + var songs: Array[SongObject] = _add_songs(2) + songs[0].players_given_point.assign(["crono-id", "marle-id"]) + songs[1].players_given_point.assign(["crono-id"]) + + Playlist.remove_player_from_list("marle-id") + + assert_array(songs[0].players_given_point).contains_exactly(["crono-id"]) + assert_array(songs[1].players_given_point).contains_exactly(["crono-id"]) + + +func test_reset_playlist_clears_songs_and_position() -> void: + _add_songs(3) + Playlist.latest_played_song = 2 + Playlist.set_currently_playing_song(1) + + Playlist.reset_playlist() + + assert_array(Playlist.song_object_array).is_empty() + assert_int(Playlist.latest_played_song).is_equal(0) + assert_int(Playlist.currently_playing_song).is_equal(-1) + + +func test_current_titles_are_hidden_until_answered() -> void: + var songs: Array[SongObject] = _add_songs(1) + Playlist.set_currently_playing_song(0) + + assert_str(Playlist.get_current_soundtrack_title()).is_equal("????????") + + songs[0].is_answered = true + + assert_str(Playlist.get_current_soundtrack_title()).is_equal("Soundtrack 0") + assert_str(Playlist.get_current_song_title()).is_equal("Song 0") + + +func test_answering_when_nothing_is_playing_does_nothing() -> void: + Settings.add_to_stats = true + var songs: Array[SongObject] = _add_songs(2) + + Playlist.song_is_answered() + + assert_bool(songs[0].is_answered).is_false() + assert_bool(songs[1].is_answered).is_false() + assert_array(_server.requested_paths).is_empty() + + +func test_answering_with_empty_playlist_does_nothing() -> void: + Playlist.song_is_answered() + + assert_array(_server.requested_paths).is_empty() diff --git a/tests/unit/test_playlist.gd.uid b/tests/unit/test_playlist.gd.uid new file mode 100644 index 0000000..5bbe695 --- /dev/null +++ b/tests/unit/test_playlist.gd.uid @@ -0,0 +1 @@ +uid://c2ibkc7bd60cx diff --git a/tests/unit/test_search.gd b/tests/unit/test_search.gd new file mode 100644 index 0000000..0b0a567 --- /dev/null +++ b/tests/unit/test_search.gd @@ -0,0 +1,64 @@ +extends GdUnitTestSuite +## How the search popup finds and orders matching soundtracks. + +const SearchWindowScript: GDScript = preload("res://popups/search_popup.gd") + +var _search: Control + + +func before_test() -> void: + _search = auto_free(SearchWindowScript.new()) + _search.set_soundtracks([ + "Chrono Trigger", + "Final Fantasy VI", + "Mario & Luigi: Superstar Saga", + "Pokémon Red", + "Q*bert", + "Super Mario 64", + "The Legend of Zelda: A Link to the Past", + ]) + + +func test_plain_text_matches_case_insensitively() -> void: + assert_array(_search.find_matches("chrono")).contains_exactly(["Chrono Trigger"]) + + +func test_contains_matches_come_before_letter_order_matches() -> void: + var matches: Array[String] = _search.find_matches("mario") + + assert_array(matches).contains_exactly([ + "Mario & Luigi: Superstar Saga", + "Super Mario 64", + ]) + + +func test_ignores_spaces() -> void: + assert_array(_search.find_matches("finalfantasy")).contains_exactly(["Final Fantasy VI"]) + + +func test_and_matches_ampersand() -> void: + assert_array(_search.find_matches("mario and luigi")) \ + .contains(["Mario & Luigi: Superstar Saga"]) + + +func test_accented_letters_match_plain_letters() -> void: + assert_array(_search.find_matches("pokemon")).contains_exactly(["Pokémon Red"]) + + +func test_letters_in_order_match_abbreviations() -> void: + assert_array(_search.find_matches("lozlttp")).contains_exactly([ + "The Legend of Zelda: A Link to the Past", + ]) + + +func test_regex_characters_are_matched_literally() -> void: + assert_array(_search.find_matches("q*b")).contains_exactly(["Q*bert"]) + + +func test_no_matches() -> void: + assert_array(_search.find_matches("metroid")).is_empty() + + +func test_clean_term() -> void: + assert_str(_search.clean_term("Rock 'n Roll Racing")).is_equal("rockandrollracing") + assert_str(_search.clean_term("Ys I & II")).is_equal("ysiandii") diff --git a/tests/unit/test_search.gd.uid b/tests/unit/test_search.gd.uid new file mode 100644 index 0000000..8520da0 --- /dev/null +++ b/tests/unit/test_search.gd.uid @@ -0,0 +1 @@ +uid://p8ygq52abebj diff --git a/tests/unit/test_settings.gd b/tests/unit/test_settings.gd new file mode 100644 index 0000000..2bfa5ca --- /dev/null +++ b/tests/unit/test_settings.gd @@ -0,0 +1,14 @@ +extends GdUnitTestSuite +## Settings: the version number. + + +func test_version_comes_from_project_settings() -> void: + assert_str(Settings.version).is_equal( + ProjectSettings.get_setting("application/config/version")) + + +func test_version_is_major_minor_patch() -> void: + # The release job tags and names builds with this version + var pattern: RegEx = RegEx.create_from_string("^\\d+\\.\\d+\\.\\d+$") + + assert_object(pattern.search(Settings.version)).is_not_null() diff --git a/tests/unit/test_settings.gd.uid b/tests/unit/test_settings.gd.uid new file mode 100644 index 0000000..f374636 --- /dev/null +++ b/tests/unit/test_settings.gd.uid @@ -0,0 +1 @@ +uid://bf4hbba7kfoab diff --git a/tests/unit/test_song_object.gd b/tests/unit/test_song_object.gd new file mode 100644 index 0000000..a72b276 --- /dev/null +++ b/tests/unit/test_song_object.gd @@ -0,0 +1,61 @@ +extends GdUnitTestSuite +## SongObject: what it shows before and after the answer, and its points. + +var _is_debug: bool +var _song: SongObject + + +func before_test() -> void: + _is_debug = Settings.is_debug + Settings.is_debug = false + _song = SongObject.new() + _song.song_number = 4 + _song.soundtrack_title = "Final Fantasy VI" + _song.song_title = "Dancing Mad" + + +func after_test() -> void: + Settings.is_debug = _is_debug + + +func test_titles_are_hidden_until_answered() -> void: + assert_str(_song.get_soundtrack_title()).is_equal("????????") + assert_str(_song.get_song_title()).is_equal("??????") + assert_str(_song.get_song_info()).is_equal("??? - ???") + + +func test_titles_are_shown_once_answered() -> void: + _song.is_answered = true + + assert_str(_song.get_soundtrack_title()).is_equal("Final Fantasy VI") + assert_str(_song.get_song_title()).is_equal("Dancing Mad") + assert_str(_song.get_song_info()).is_equal("5. Final Fantasy VI - Dancing Mad") + + +func test_debug_info_shows_unanswered_songs() -> void: + Settings.is_debug = true + + assert_str(_song.get_song_info()).is_equal( + "5. Final Fantasy VI - Dancing Mad | played: false | answered: false") + + +func test_points_are_kept_in_the_order_given() -> void: + _song.add_point("marle-id") + _song.add_point("crono-id") + + assert_array(_song.players_given_point).contains_exactly(["marle-id", "crono-id"]) + + +func test_player_gets_at_most_one_point_per_song() -> void: + _song.add_point("crono-id") + _song.add_point("crono-id") + + assert_array(_song.players_given_point).contains_exactly(["crono-id"]) + + +func test_removing_point_of_player_without_point_changes_nothing() -> void: + _song.add_point("crono-id") + + _song.remove_point("marle-id") + + assert_array(_song.players_given_point).contains_exactly(["crono-id"]) diff --git a/tests/unit/test_song_object.gd.uid b/tests/unit/test_song_object.gd.uid new file mode 100644 index 0000000..c459625 --- /dev/null +++ b/tests/unit/test_song_object.gd.uid @@ -0,0 +1 @@ +uid://cfc78daetbf6i diff --git a/themes/README.md b/themes/README.md new file mode 100644 index 0000000..0ebd236 --- /dev/null +++ b/themes/README.md @@ -0,0 +1,52 @@ +# Themes + +The whole look of the game comes from one theme, `main_theme.tres`. It is the project +theme (Project Settings → GUI → Theme → Custom), so every scene uses it without setting +anything. Scenes and scripts never set colours, font sizes or style boxes themselves; +they name one of the theme's styles instead, and `tests/integration/test_theme.gd` +checks that it stays that way. + +## Making a new theme + +1. Copy `main_theme.tres`, e.g. to `dark_blue_theme.tres`. +2. Open the copy in Godot (double-click it) and change its colours, style boxes and font + sizes in the theme editor. Keep every style's name. +3. Point Project Settings → GUI → Theme → Custom at the copy. + +Icons (play, restart, add player, the slider and switch knobs) are not part of the theme; +they come from the image files in `icons/` and Godot's built-in theme. + +## What is in the theme + +**Godot's default look** for every kind of control the game uses (Button, Label, Panel, +CheckButton, sliders, scroll bars, text fields and so on), copied in so it can be changed +here. + +**Named styles** (theme type variations), picked with *Theme Type Variation* in a node's +inspector or `theme_type_variation` in code: + +| Style | Based on | Used for | +|---|---|---| +| `WindowBackground` | Panel | The background behind everything in the main window | +| `PopupBackground` | Panel | The panel of every popup: dark, bordered and rounded | +| `DarkSpacer` | Panel | The dark gaps in the player list and the add player row | +| `CharacterCard` | Panel | A character in the character select popup | +| `MusicList` | PanelContainer | The list of songs in the quiz | +| `SongRow` | PanelContainer | One song in that list | +| `ShortcutRowDark` | PanelContainer | Every other row in the shortcuts popup | +| `ListRowEven`, `ListRowOdd` | PanelContainer | Alternating rows in the inspiration and sync lists | +| `TitleLarge` | Label | The soundtrack that is playing (50) | +| `TitleMedium` | Label | The song that is playing, and the winner (35) | +| `Title` | Label | The window title and popup titles (25) | +| `TextLarge` | Label | Log rows, shortcuts and the inspiration list (20) | +| `TextMedium` | Label | Rows in the sync result (18) | +| `HighlightLarge`, `HighlightMedium` | Label | Orange text: sync headers and every other sync row | +| `SongRowLabel`, `SongRowLabelPlaying` | Label | A song's text, and the one that is playing | + +The **PopupPanel** type itself is styled too, for the Statistics popup. + +**Palette**: colours used from code. + +| Colour | Used for | +|---|---| +| `backdrop` | The dimming behind an open popup | diff --git a/themes/main_theme.tres b/themes/main_theme.tres new file mode 100644 index 0000000..ecfdaf3 --- /dev/null +++ b/themes/main_theme.tres @@ -0,0 +1,916 @@ +[gd_resource type="Theme" format=3] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_vxwtt"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(0.1, 0.1, 0.1, 0.3) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rjgu6"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.75) +draw_center = false +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 +expand_margin_left = 2.0 +expand_margin_top = 2.0 +expand_margin_right = 2.0 +expand_margin_bottom = 2.0 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_vu6l5"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(0.225, 0.225, 0.225, 0.6) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ydht1"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(0.1, 0.1, 0.1, 0.6) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_8gw6x"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(0, 0, 0, 0.6) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_g21io"] +bg_color = Color(0.302, 0.302, 0.302, 1) + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_7ceur"] +content_margin_left = 6.0 +content_margin_top = 4.0 +content_margin_right = 6.0 +content_margin_bottom = 4.0 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_uobgu"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.75) +draw_center = false +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 +expand_margin_left = 2.0 +expand_margin_top = 2.0 +expand_margin_right = 2.0 +expand_margin_bottom = 2.0 + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_puucq"] +content_margin_left = 6.0 +content_margin_top = 4.0 +content_margin_right = 6.0 +content_margin_bottom = 4.0 + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1mjdo"] +content_margin_left = 6.0 +content_margin_top = 4.0 +content_margin_right = 6.0 +content_margin_bottom = 4.0 + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_s5e0q"] +content_margin_left = 6.0 +content_margin_top = 4.0 +content_margin_right = 6.0 +content_margin_bottom = 4.0 + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_0x86e"] +content_margin_left = 6.0 +content_margin_top = 4.0 +content_margin_right = 6.0 +content_margin_bottom = 4.0 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_f5ubn"] +bg_color = Color(0.180392, 0.180392, 0.180392, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7qy2x"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.4) +corner_radius_top_left = 10 +corner_radius_top_right = 10 +corner_radius_bottom_right = 10 +corner_radius_bottom_left = 10 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_myxx4"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.75) +corner_radius_top_left = 10 +corner_radius_top_right = 10 +corner_radius_bottom_right = 10 +corner_radius_bottom_left = 10 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_3kvey"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(0.75, 0.75, 0.75, 0.75) +corner_radius_top_left = 10 +corner_radius_top_right = 10 +corner_radius_bottom_right = 10 +corner_radius_bottom_left = 10 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5iri2"] +content_margin_left = 0.0 +content_margin_top = 4.0 +content_margin_right = 0.0 +content_margin_bottom = 4.0 +bg_color = Color(0.1, 0.1, 0.1, 0.6) +corner_radius_top_left = 10 +corner_radius_top_right = 10 +corner_radius_bottom_right = 10 +corner_radius_bottom_left = 10 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_3scot"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.75) +draw_center = false +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 +expand_margin_left = 2.0 +expand_margin_top = 2.0 +expand_margin_right = 2.0 +expand_margin_bottom = 2.0 + +[sub_resource type="StyleBoxLine" id="StyleBoxLine_puicy"] +content_margin_left = 4.0 +content_margin_top = 0.0 +content_margin_right = 4.0 +content_margin_bottom = 0.0 +color = Color(0.5, 0.5, 0.5, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_quqgn"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.4) +corner_radius_top_left = 4 +corner_radius_top_right = 4 +corner_radius_bottom_right = 4 +corner_radius_bottom_left = 4 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_m01q4"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.75) +corner_radius_top_left = 4 +corner_radius_top_right = 4 +corner_radius_bottom_right = 4 +corner_radius_bottom_left = 4 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_k36pm"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(0.1, 0.1, 0.1, 0.6) +corner_radius_top_left = 4 +corner_radius_top_right = 4 +corner_radius_bottom_right = 4 +corner_radius_bottom_left = 4 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6h40v"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.75) +draw_center = false +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 +expand_margin_left = 2.0 +expand_margin_top = 2.0 +expand_margin_right = 2.0 +expand_margin_bottom = 2.0 + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_s47sh"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_u5sc5"] +content_margin_left = 8.0 +content_margin_top = 2.0 +content_margin_right = 8.0 +content_margin_bottom = 2.0 +bg_color = Color(1, 1, 1, 0.05) +corner_radius_top_left = 4 +corner_radius_top_right = 4 +corner_radius_bottom_right = 4 +corner_radius_bottom_left = 4 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_mxy8k"] +content_margin_left = 8.0 +content_margin_top = 2.0 +content_margin_right = 8.0 +content_margin_bottom = 2.0 +bg_color = Color(1, 1, 1, 0.11) +corner_radius_top_left = 4 +corner_radius_top_right = 4 +corner_radius_bottom_right = 4 +corner_radius_bottom_left = 4 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_33f1l"] +bg_color = Color(0.257813, 0.257813, 0.257813, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wbnll"] +content_margin_left = 8.0 +content_margin_top = 4.0 +content_margin_right = 8.0 +content_margin_bottom = 4.0 +bg_color = Color(0.1, 0.1, 0.1, 0.3) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bmtpa"] +content_margin_left = 8.0 +content_margin_top = 4.0 +content_margin_right = 8.0 +content_margin_bottom = 4.0 +bg_color = Color(0.1, 0.1, 0.1, 0.3) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_f5lmb"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.75) +draw_center = false +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 +expand_margin_left = 2.0 +expand_margin_top = 2.0 +expand_margin_right = 2.0 +expand_margin_bottom = 2.0 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_qtlom"] +content_margin_left = 8.0 +content_margin_top = 4.0 +content_margin_right = 8.0 +content_margin_bottom = 4.0 +bg_color = Color(0.225, 0.225, 0.225, 0.6) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_t2i82"] +content_margin_left = 8.0 +content_margin_top = 4.0 +content_margin_right = 8.0 +content_margin_bottom = 4.0 +bg_color = Color(0.225, 0.225, 0.225, 0.6) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_a4gt2"] +content_margin_left = 8.0 +content_margin_top = 4.0 +content_margin_right = 8.0 +content_margin_bottom = 4.0 +bg_color = Color(0.1, 0.1, 0.1, 0.6) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_uisqs"] +content_margin_left = 8.0 +content_margin_top = 4.0 +content_margin_right = 8.0 +content_margin_bottom = 4.0 +bg_color = Color(0.1, 0.1, 0.1, 0.6) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dinf2"] +content_margin_left = 8.0 +content_margin_top = 4.0 +content_margin_right = 8.0 +content_margin_bottom = 4.0 +bg_color = Color(0, 0, 0, 0.6) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_a0ksy"] +content_margin_left = 8.0 +content_margin_top = 4.0 +content_margin_right = 8.0 +content_margin_bottom = 4.0 +bg_color = Color(0, 0, 0, 0.6) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hwwyi"] +content_margin_left = 0.0 +content_margin_top = 0.0 +content_margin_right = 0.0 +content_margin_bottom = 0.0 +bg_color = Color(0.1, 0.1, 0.1, 0.6) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wl3gy"] +content_margin_left = 0.0 +content_margin_top = 0.0 +content_margin_right = 0.0 +content_margin_bottom = 0.0 +bg_color = Color(0.1, 0.1, 0.1, 0.6) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_vputg"] +content_margin_left = 12.0 +content_margin_top = 12.0 +content_margin_right = 12.0 +content_margin_bottom = 12.0 +bg_color = Color(0.180392, 0.180392, 0.180392, 1) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.36, 0.36, 0.36, 1) +corner_radius_top_left = 6 +corner_radius_top_right = 6 +corner_radius_bottom_right = 6 +corner_radius_bottom_left = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_1de2m"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(0.4, 0.4, 0.4, 1) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxLine" id="StyleBoxLine_bacd5"] +content_margin_left = 4.0 +content_margin_top = 0.0 +content_margin_right = 4.0 +content_margin_bottom = 0.0 +color = Color(0.5, 0.5, 0.5, 1) + +[sub_resource type="StyleBoxLine" id="StyleBoxLine_k3nwn"] +content_margin_left = 4.0 +content_margin_top = 0.0 +content_margin_right = 4.0 +content_margin_bottom = 0.0 +color = Color(0.5, 0.5, 0.5, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_axpob"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(0.25, 0.25, 0.25, 1) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.175, 0.175, 0.175, 1) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxLine" id="StyleBoxLine_cl37w"] +content_margin_left = 4.0 +content_margin_top = 0.0 +content_margin_right = 4.0 +content_margin_bottom = 0.0 +color = Color(0.5, 0.5, 0.5, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_07kyi"] +content_margin_left = 2.0 +content_margin_top = 2.0 +content_margin_right = 2.0 +content_margin_bottom = 2.0 +bg_color = Color(0.1, 0.1, 0.1, 0.3) +corner_radius_top_left = 6 +corner_radius_top_right = 6 +corner_radius_bottom_right = 6 +corner_radius_bottom_left = 6 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ouuwv"] +content_margin_left = 2.0 +content_margin_top = 2.0 +content_margin_right = 2.0 +content_margin_bottom = 2.0 +bg_color = Color(1, 1, 1, 0.4) +corner_radius_top_left = 6 +corner_radius_top_right = 6 +corner_radius_bottom_right = 6 +corner_radius_bottom_left = 6 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_35dwi"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.75) +draw_center = false +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(1, 1, 1, 0.75) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 +expand_margin_left = 4.0 +expand_margin_top = 4.0 +expand_margin_right = 4.0 +expand_margin_bottom = 4.0 + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_r3gy8"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_anjdx"] +content_margin_left = 0.0 +content_margin_top = 0.0 +content_margin_right = 0.0 +content_margin_bottom = 0.0 +bg_color = Color(0.18, 0.18, 0.18, 1) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oxgwg"] +bg_color = Color(0.180392, 0.180392, 0.180392, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_r1nnu"] +bg_color = Color(0.180392, 0.180392, 0.180392, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lkqs8"] +bg_color = Color(0.180392, 0.180392, 0.180392, 1) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_1tv07"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.75) +draw_center = false +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 +expand_margin_left = 2.0 +expand_margin_top = 2.0 +expand_margin_right = 2.0 +expand_margin_bottom = 2.0 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0ly2h"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(0.1, 0.1, 0.1, 0.6) +border_width_bottom = 2 +border_color = Color(0, 0, 0, 0.6) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_o0qds"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(0.1, 0.1, 0.1, 0.3) +border_width_bottom = 2 +border_color = Color(0, 0, 0, 0.3) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ogk6w"] +content_margin_left = 8.0 +content_margin_top = 2.0 +content_margin_right = 8.0 +content_margin_bottom = 2.0 +bg_color = Color(0, 0, 0, 0.5) +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dwgvb"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.4) +corner_radius_top_left = 10 +corner_radius_top_right = 10 +corner_radius_bottom_right = 10 +corner_radius_bottom_left = 10 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_uoaln"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.75) +corner_radius_top_left = 10 +corner_radius_top_right = 10 +corner_radius_bottom_right = 10 +corner_radius_bottom_left = 10 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_x5u8n"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(0.75, 0.75, 0.75, 0.75) +corner_radius_top_left = 10 +corner_radius_top_right = 10 +corner_radius_bottom_right = 10 +corner_radius_bottom_left = 10 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6fedo"] +content_margin_left = 4.0 +content_margin_top = 0.0 +content_margin_right = 4.0 +content_margin_bottom = 0.0 +bg_color = Color(0.1, 0.1, 0.1, 0.6) +corner_radius_top_left = 10 +corner_radius_top_right = 10 +corner_radius_bottom_right = 10 +corner_radius_bottom_left = 10 +corner_detail = 6 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_8sdcv"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 4.0 +bg_color = Color(1, 1, 1, 0.75) +draw_center = false +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +corner_radius_top_left = 3 +corner_radius_top_right = 3 +corner_radius_bottom_right = 3 +corner_radius_bottom_left = 3 +corner_detail = 5 +expand_margin_left = 2.0 +expand_margin_top = 2.0 +expand_margin_right = 2.0 +expand_margin_bottom = 2.0 + +[sub_resource type="StyleBoxLine" id="StyleBoxLine_r3erx"] +content_margin_left = 0.0 +content_margin_top = 4.0 +content_margin_right = 0.0 +content_margin_bottom = 4.0 +color = Color(0.5, 0.5, 0.5, 1) +vertical = true + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2mdtn"] +bg_color = Color(0.3, 0.3, 0.3, 1) + +[resource] +Button/colors/font_color = Color(0.875, 0.875, 0.875, 1) +Button/colors/font_disabled_color = Color(0.875, 0.875, 0.875, 0.5) +Button/colors/font_focus_color = Color(0.95, 0.95, 0.95, 1) +Button/colors/font_hover_color = Color(0.95, 0.95, 0.95, 1) +Button/colors/font_hover_pressed_color = Color(1, 1, 1, 1) +Button/colors/font_outline_color = Color(0, 0, 0, 1) +Button/colors/font_pressed_color = Color(1, 1, 1, 1) +Button/colors/icon_disabled_color = Color(1, 1, 1, 0.4) +Button/colors/icon_focus_color = Color(1, 1, 1, 1) +Button/colors/icon_hover_color = Color(1, 1, 1, 1) +Button/colors/icon_hover_pressed_color = Color(1, 1, 1, 1) +Button/colors/icon_normal_color = Color(1, 1, 1, 1) +Button/colors/icon_pressed_color = Color(1, 1, 1, 1) +Button/constants/align_to_largest_stylebox = 0 +Button/constants/h_separation = 4 +Button/constants/icon_max_width = 0 +Button/constants/outline_size = 0 +Button/font_sizes/font_size = 16 +Button/styles/disabled = SubResource("StyleBoxFlat_vxwtt") +Button/styles/focus = SubResource("StyleBoxFlat_rjgu6") +Button/styles/hover = SubResource("StyleBoxFlat_vu6l5") +Button/styles/normal = SubResource("StyleBoxFlat_ydht1") +Button/styles/pressed = SubResource("StyleBoxFlat_8gw6x") +CharacterCard/base_type = &"Panel" +CharacterCard/styles/panel = SubResource("StyleBoxFlat_g21io") +CheckButton/colors/button_checked_color = Color(1, 1, 1, 1) +CheckButton/colors/button_unchecked_color = Color(1, 1, 1, 1) +CheckButton/colors/font_color = Color(0.875, 0.875, 0.875, 1) +CheckButton/colors/font_disabled_color = Color(0.875, 0.875, 0.875, 0.5) +CheckButton/colors/font_focus_color = Color(0.95, 0.95, 0.95, 1) +CheckButton/colors/font_hover_color = Color(0.95, 0.95, 0.95, 1) +CheckButton/colors/font_hover_pressed_color = Color(1, 1, 1, 1) +CheckButton/colors/font_outline_color = Color(0, 0, 0, 1) +CheckButton/colors/font_pressed_color = Color(1, 1, 1, 1) +CheckButton/constants/check_v_offset = 0 +CheckButton/constants/h_separation = 4 +CheckButton/constants/outline_size = 0 +CheckButton/font_sizes/font_size = 16 +CheckButton/styles/disabled = SubResource("StyleBoxEmpty_7ceur") +CheckButton/styles/focus = SubResource("StyleBoxFlat_uobgu") +CheckButton/styles/hover = SubResource("StyleBoxEmpty_puucq") +CheckButton/styles/hover_pressed = SubResource("StyleBoxEmpty_1mjdo") +CheckButton/styles/normal = SubResource("StyleBoxEmpty_s5e0q") +CheckButton/styles/pressed = SubResource("StyleBoxEmpty_0x86e") +DarkSpacer/base_type = &"Panel" +DarkSpacer/styles/panel = SubResource("StyleBoxFlat_f5ubn") +GridContainer/constants/h_separation = 4 +GridContainer/constants/v_separation = 4 +HBoxContainer/constants/separation = 4 +HScrollBar/styles/grabber = SubResource("StyleBoxFlat_7qy2x") +HScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_myxx4") +HScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_3kvey") +HScrollBar/styles/scroll = SubResource("StyleBoxFlat_5iri2") +HScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_3scot") +HSeparator/constants/separation = 4 +HSeparator/styles/separator = SubResource("StyleBoxLine_puicy") +HSlider/constants/center_grabber = 0 +HSlider/constants/grabber_offset = 0 +HSlider/constants/tick_offset = 0 +HSlider/styles/grabber_area = SubResource("StyleBoxFlat_quqgn") +HSlider/styles/grabber_area_highlight = SubResource("StyleBoxFlat_m01q4") +HSlider/styles/slider = SubResource("StyleBoxFlat_k36pm") +HighlightLarge/base_type = &"Label" +HighlightLarge/colors/font_color = Color(1, 0.5, 0, 1) +HighlightLarge/font_sizes/font_size = 20 +HighlightMedium/base_type = &"Label" +HighlightMedium/colors/font_color = Color(1, 0.5, 0, 1) +HighlightMedium/font_sizes/font_size = 18 +Label/colors/font_color = Color(1, 1, 1, 1) +Label/colors/font_outline_color = Color(0, 0, 0, 1) +Label/colors/font_shadow_color = Color(0, 0, 0, 0) +Label/constants/line_spacing = 3 +Label/constants/outline_size = 0 +Label/constants/shadow_offset_x = 1 +Label/constants/shadow_offset_y = 1 +Label/constants/shadow_outline_size = 1 +Label/font_sizes/font_size = 16 +Label/styles/focus = SubResource("StyleBoxFlat_6h40v") +Label/styles/normal = SubResource("StyleBoxEmpty_s47sh") +ListRowEven/base_type = &"PanelContainer" +ListRowEven/styles/panel = SubResource("StyleBoxFlat_u5sc5") +ListRowOdd/base_type = &"PanelContainer" +ListRowOdd/styles/panel = SubResource("StyleBoxFlat_mxy8k") +MarginContainer/constants/margin_bottom = 0 +MarginContainer/constants/margin_left = 0 +MarginContainer/constants/margin_right = 0 +MarginContainer/constants/margin_top = 0 +MusicList/base_type = &"PanelContainer" +MusicList/styles/panel = SubResource("StyleBoxFlat_33f1l") +OptionButton/colors/font_color = Color(0.875, 0.875, 0.875, 1) +OptionButton/colors/font_disabled_color = Color(0.875, 0.875, 0.875, 0.5) +OptionButton/colors/font_focus_color = Color(0.95, 0.95, 0.95, 1) +OptionButton/colors/font_hover_color = Color(0.95, 0.95, 0.95, 1) +OptionButton/colors/font_hover_pressed_color = Color(1, 1, 1, 1) +OptionButton/colors/font_outline_color = Color(0, 0, 0, 1) +OptionButton/colors/font_pressed_color = Color(1, 1, 1, 1) +OptionButton/constants/arrow_margin = 4 +OptionButton/constants/h_separation = 4 +OptionButton/constants/modulate_arrow = 0 +OptionButton/constants/outline_size = 0 +OptionButton/font_sizes/font_size = 16 +OptionButton/styles/disabled = SubResource("StyleBoxFlat_wbnll") +OptionButton/styles/disabled_mirrored = SubResource("StyleBoxFlat_bmtpa") +OptionButton/styles/focus = SubResource("StyleBoxFlat_f5lmb") +OptionButton/styles/hover = SubResource("StyleBoxFlat_qtlom") +OptionButton/styles/hover_mirrored = SubResource("StyleBoxFlat_t2i82") +OptionButton/styles/normal = SubResource("StyleBoxFlat_a4gt2") +OptionButton/styles/normal_mirrored = SubResource("StyleBoxFlat_uisqs") +OptionButton/styles/pressed = SubResource("StyleBoxFlat_dinf2") +OptionButton/styles/pressed_mirrored = SubResource("StyleBoxFlat_a0ksy") +Palette/colors/backdrop = Color(0, 0, 0, 0.55) +Panel/styles/panel = SubResource("StyleBoxFlat_hwwyi") +PanelContainer/styles/panel = SubResource("StyleBoxFlat_wl3gy") +PopupBackground/base_type = &"Panel" +PopupBackground/styles/panel = SubResource("StyleBoxFlat_vputg") +PopupMenu/colors/font_accelerator_color = Color(0.7, 0.7, 0.7, 0.8) +PopupMenu/colors/font_color = Color(0.875, 0.875, 0.875, 1) +PopupMenu/colors/font_disabled_color = Color(0.4, 0.4, 0.4, 0.8) +PopupMenu/colors/font_hover_color = Color(0.875, 0.875, 0.875, 1) +PopupMenu/colors/font_outline_color = Color(0, 0, 0, 1) +PopupMenu/colors/font_separator_color = Color(0.875, 0.875, 0.875, 1) +PopupMenu/colors/font_separator_outline_color = Color(0, 0, 0, 1) +PopupMenu/constants/gutter_compact = 1 +PopupMenu/constants/h_separation = 4 +PopupMenu/constants/icon_max_width = 0 +PopupMenu/constants/indent = 10 +PopupMenu/constants/item_end_padding = 2 +PopupMenu/constants/item_start_padding = 2 +PopupMenu/constants/outline_size = 0 +PopupMenu/constants/search_bar_separation = 4 +PopupMenu/constants/separator_outline_size = 0 +PopupMenu/constants/v_separation = 4 +PopupMenu/font_sizes/font_separator_size = 16 +PopupMenu/font_sizes/font_size = 16 +PopupMenu/styles/hover = SubResource("StyleBoxFlat_1de2m") +PopupMenu/styles/labeled_separator_left = SubResource("StyleBoxLine_bacd5") +PopupMenu/styles/labeled_separator_right = SubResource("StyleBoxLine_k3nwn") +PopupMenu/styles/panel = SubResource("StyleBoxFlat_axpob") +PopupMenu/styles/separator = SubResource("StyleBoxLine_cl37w") +PopupPanel/styles/panel = SubResource("StyleBoxFlat_vputg") +ProgressBar/colors/font_color = Color(0.95, 0.95, 0.95, 1) +ProgressBar/colors/font_outline_color = Color(0, 0, 0, 1) +ProgressBar/constants/outline_size = 0 +ProgressBar/font_sizes/font_size = 16 +ProgressBar/styles/background = SubResource("StyleBoxFlat_07kyi") +ProgressBar/styles/fill = SubResource("StyleBoxFlat_ouuwv") +ScrollContainer/colors/scroll_hint_horizontal_color = Color(0, 0, 0, 1) +ScrollContainer/colors/scroll_hint_vertical_color = Color(0, 0, 0, 1) +ScrollContainer/styles/focus = SubResource("StyleBoxFlat_35dwi") +ScrollContainer/styles/panel = SubResource("StyleBoxEmpty_r3gy8") +ShortcutRowDark/base_type = &"PanelContainer" +ShortcutRowDark/styles/panel = SubResource("StyleBoxFlat_anjdx") +SongRow/base_type = &"PanelContainer" +SongRow/styles/panel = SubResource("StyleBoxFlat_oxgwg") +SongRowLabel/base_type = &"Label" +SongRowLabel/font_sizes/font_size = 20 +SongRowLabel/styles/normal = SubResource("StyleBoxFlat_r1nnu") +SongRowLabelPlaying/base_type = &"Label" +SongRowLabelPlaying/colors/font_color = Color(1, 0.5, 0, 1) +SongRowLabelPlaying/font_sizes/font_size = 20 +SongRowLabelPlaying/styles/normal = SubResource("StyleBoxFlat_lkqs8") +TextEdit/colors/background_color = Color(0, 0, 0, 0) +TextEdit/colors/caret_background_color = Color(0, 0, 0, 1) +TextEdit/colors/caret_color = Color(0.875, 0.875, 0.875, 1) +TextEdit/colors/current_line_color = Color(0.25, 0.25, 0.26, 0.8) +TextEdit/colors/font_color = Color(0.875, 0.875, 0.875, 1) +TextEdit/colors/font_outline_color = Color(0, 0, 0, 1) +TextEdit/colors/font_placeholder_color = Color(0.875, 0.875, 0.875, 0.6) +TextEdit/colors/font_readonly_color = Color(0.875, 0.875, 0.875, 0.5) +TextEdit/colors/font_selected_color = Color(0, 0, 0, 0) +TextEdit/colors/search_result_border_color = Color(0.3, 0.3, 0.3, 0.4) +TextEdit/colors/search_result_color = Color(0.3, 0.3, 0.3, 1) +TextEdit/colors/selection_color = Color(0.5, 0.5, 0.5, 1) +TextEdit/colors/word_highlighted_color = Color(0.5, 0.5, 0.5, 0.25) +TextEdit/constants/caret_width = 1 +TextEdit/constants/line_spacing = 4 +TextEdit/constants/outline_size = 0 +TextEdit/constants/wrap_offset = 10 +TextEdit/font_sizes/font_size = 16 +TextEdit/styles/focus = SubResource("StyleBoxFlat_1tv07") +TextEdit/styles/normal = SubResource("StyleBoxFlat_0ly2h") +TextEdit/styles/read_only = SubResource("StyleBoxFlat_o0qds") +TextLarge/base_type = &"Label" +TextLarge/font_sizes/font_size = 20 +TextMedium/base_type = &"Label" +TextMedium/font_sizes/font_size = 18 +Title/base_type = &"Label" +Title/font_sizes/font_size = 25 +TitleLarge/base_type = &"Label" +TitleLarge/font_sizes/font_size = 50 +TitleMedium/base_type = &"Label" +TitleMedium/font_sizes/font_size = 35 +TooltipLabel/colors/font_color = Color(0.875, 0.875, 0.875, 1) +TooltipLabel/colors/font_outline_color = Color(0, 0, 0, 1) +TooltipLabel/colors/font_shadow_color = Color(0, 0, 0, 0) +TooltipLabel/constants/outline_size = 0 +TooltipLabel/constants/shadow_offset_x = 1 +TooltipLabel/constants/shadow_offset_y = 1 +TooltipLabel/font_sizes/font_size = 16 +TooltipPanel/styles/panel = SubResource("StyleBoxFlat_ogk6w") +VBoxContainer/constants/separation = 4 +VScrollBar/styles/grabber = SubResource("StyleBoxFlat_dwgvb") +VScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_uoaln") +VScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_x5u8n") +VScrollBar/styles/scroll = SubResource("StyleBoxFlat_6fedo") +VScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_8sdcv") +VSeparator/constants/separation = 4 +VSeparator/styles/separator = SubResource("StyleBoxLine_r3erx") +WindowBackground/base_type = &"Panel" +WindowBackground/styles/panel = SubResource("StyleBoxFlat_2mdtn") diff --git a/tools/compare_screenshots.py b/tools/compare_screenshots.py new file mode 100755 index 0000000..b9728f5 --- /dev/null +++ b/tools/compare_screenshots.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Compares two folders of screenshots from tools/take_screenshots.gd pixel by pixel. + +Usage: tools/compare_screenshots.py [] + +Prints how many pixels differ in each screenshot. With a diff dir, writes an image +for each screenshot that differs, with the changed pixels in red over a faded copy of +the after picture. Exits with 1 if any screenshot differs or is missing. +""" + +import sys +from pathlib import Path + +from PIL import Image, ImageChops + + +def main() -> int: + if len(sys.argv) < 3: + print(__doc__) + return 2 + before_dir, after_dir = Path(sys.argv[1]), Path(sys.argv[2]) + diff_dir = Path(sys.argv[3]) if len(sys.argv) > 3 else None + if diff_dir: + diff_dir.mkdir(parents=True, exist_ok=True) + + names = sorted({p.name for p in before_dir.glob("*.png")} + | {p.name for p in after_dir.glob("*.png")}) + failed = False + for name in names: + before_path, after_path = before_dir / name, after_dir / name + if not before_path.exists() or not after_path.exists(): + print(f"{name}: missing in {'before' if not before_path.exists() else 'after'}") + failed = True + continue + before = Image.open(before_path).convert("RGB") + after = Image.open(after_path).convert("RGB") + if before.size != after.size: + print(f"{name}: size {before.size} -> {after.size}") + failed = True + continue + mask = ImageChops.difference(before, after).convert("L").point( + lambda value: 255 if value else 0) + changed = mask.histogram()[255] + print(f"{name}: {changed} pixels differ") + if changed: + failed = True + if diff_dir: + faded = Image.blend(after, Image.new("RGB", after.size, "white"), 0.7) + faded.paste(Image.new("RGB", after.size, "red"), mask=mask) + faded.save(diff_dir / name) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/take_screenshots.gd b/tools/take_screenshots.gd new file mode 100644 index 0000000..1ac2973 --- /dev/null +++ b/tools/take_screenshots.gd @@ -0,0 +1,53 @@ +extends SceneTree +## Takes a screenshot of the main window in every UiTour state, against a fake server. +## +## Usage: +## godot --path . -s tools/take_screenshots.gd -- +## +## Writes [code].png[/code] for each state and [code]layout.json[/code] with where +## every node is in each state. With [code]--headless[/code] only the layout is written. +## Compare two runs with [code]tools/compare_screenshots.py[/code]. + +const SIZE: Vector2i = Vector2i(1920, 1080) + + +func _initialize() -> void: + _take.call_deferred() + + +func _take() -> void: + var args: PackedStringArray = OS.get_cmdline_user_args() + var out_dir: String = args[0] if !args.is_empty() else "user://screenshots" + DirAccess.make_dir_recursive_absolute(out_dir) + var draws: bool = DisplayServer.get_name() != "headless" + + # Loaded here, not named as a class: autoloads such as Playlist do not exist yet + # when this script is compiled + var ui_tour: GDScript = load("res://tests/fakes/ui_tour.gd") + ui_tour.prepare_autoloads() + var server: RefCounted = ui_tour.make_server().install() + + # Drawn at the layout size, so the pictures do not depend on the screen size + var viewport: SubViewport = SubViewport.new() + viewport.size = SIZE + viewport.gui_embed_subwindows = true + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var main: Control = load("res://main/MainWindow.tscn").instantiate() + viewport.add_child(main) + + var layout: Dictionary = {} + var save_state: Callable = func(state: String, popup: Node) -> void: + layout[state] = ui_tour.dump_layout(main, main if popup == null else popup) + if draws: + await RenderingServer.frame_post_draw + viewport.get_texture().get_image().save_png(out_dir.path_join(state + ".png")) + print("Saved state ", state) + await ui_tour.new(main).run(save_state) + + var file: FileAccess = FileAccess.open(out_dir.path_join("layout.json"), FileAccess.WRITE) + file.store_string(JSON.stringify(layout, " ", true)) + file.close() + server.uninstall() + main.queue_free() + quit() diff --git a/tools/take_screenshots.gd.uid b/tools/take_screenshots.gd.uid new file mode 100644 index 0000000..2d6c498 --- /dev/null +++ b/tools/take_screenshots.gd.uid @@ -0,0 +1 @@ +uid://0837em1gsrrx