summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
authorradiohotline <radoihotline@disroot.rg>2026-08-25 16:33:56 +0300
committerradiohotline <radoihotline@disroot.rg>2026-08-25 16:33:56 +0300
commita89e744ed0e8c759caf041a580112365bbfe4303 (patch)
treedd61e320904176995727e86a857d00c90d4933ba /addons
parent6d53d8cac78250dc888aa49d5f9d4175c6652feb (diff)
escoria updateHEADmain
Diffstat (limited to 'addons')
-rw-r--r--addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.gd80
-rw-r--r--addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.gd.uid1
-rw-r--r--addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.tscn57
-rw-r--r--addons/escoria-core/editor/migration_4_6_4_7/migrator_4_6_4_7.gd107
-rw-r--r--addons/escoria-core/editor/migration_4_6_4_7/migrator_4_6_4_7.gd.uid1
-rw-r--r--addons/escoria-core/game/core-scripts/behaviors/esc_movable.gd5
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/accept_input.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/anim.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/anim_block.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/block_say.gd6
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_push.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_push_block.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_set_limits.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_set_pos.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_set_pos_block.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_set_target.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_set_target_block.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_block.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_height.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_height_block.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_shift.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/camera_shift_block.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/change_scene.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/change_scene_godot.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/custom.gd6
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/dec_global.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/enable_terrain.gd7
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/end_block_say.gd6
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/hide_menu.gd4
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/inc_global.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/inventory_add.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/inventory_remove.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/play_snd.gd15
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/print_internal.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/queue_event.gd4
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/queue_resource.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/rand_global.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/repeat.gd4
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/save_game.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/say.gd10
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/sched_event.gd6
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/set_active.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/set_active_if_exists.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/set_angle.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/set_animations.gd8
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/set_direction.gd6
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/set_global.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/set_globals.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/set_gui_visible.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/set_interactive.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/set_item_custom_data.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/set_speed.gd9
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/set_state.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/show_menu.gd4
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/slide.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/slide_block.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/spawn.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/stop.gd4
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/stop_snd.gd26
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/teleport.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/teleport_pos.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/transition.gd6
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/turn_to.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/wait.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/walk.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/walk_block.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/walk_to_pos.gd4
-rw-r--r--addons/escoria-core/game/core-scripts/esc/commands/walk_to_pos_block.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_break_counter.gd75
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_environment.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_expr.gd4
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_stmt.gd4
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_stmts.gd62
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter.gd249
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter_factory.gd64
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_parse_error.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_parser.gd205
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_resolver.gd12
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_scanner.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_script_builder.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/compiler/esc_token.gd12
-rw-r--r--addons/escoria-core/game/core-scripts/esc/esc_action_manager.gd237
-rw-r--r--addons/escoria-core/game/core-scripts/esc/esc_command_registry.gd5
-rw-r--r--addons/escoria-core/game/core-scripts/esc/esc_compiler.gd23
-rw-r--r--addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd156
-rw-r--r--addons/escoria-core/game/core-scripts/esc/esc_globals_manager.gd15
-rw-r--r--addons/escoria-core/game/core-scripts/esc/esc_inventory_manager.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/esc_object_manager.gd119
-rw-r--r--addons/escoria-core/game/core-scripts/esc/esc_room_manager.gd55
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_base_command.gd10
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_camera_base_command.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_command.gd62
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_command_argument_descriptor.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_dialog.gd42
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_dialog_option.gd16
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_event.gd20
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_events_container.gd52
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_events_container.gd.uid1
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_execution.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_object.gd65
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_room_container.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_room_objects.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_room_objects_key.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_room_terrains.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_scheduled_event.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_script.gd44
-rw-r--r--addons/escoria-core/game/core-scripts/esc/types/esc_statement.gd4
-rw-r--r--addons/escoria-core/game/core-scripts/esc_animation_player.gd36
-rw-r--r--addons/escoria-core/game/core-scripts/esc_background.gd4
-rw-r--r--addons/escoria-core/game/core-scripts/esc_dialog_location.gd6
-rw-r--r--addons/escoria-core/game/core-scripts/esc_exit.gd10
-rw-r--r--addons/escoria-core/game/core-scripts/esc_game.gd32
-rw-r--r--addons/escoria-core/game/core-scripts/esc_interaction_location.gd6
-rw-r--r--addons/escoria-core/game/core-scripts/esc_item.gd81
-rw-r--r--addons/escoria-core/game/core-scripts/esc_location.gd13
-rw-r--r--addons/escoria-core/game/core-scripts/esc_player.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/esc_resource_cache.gd29
-rw-r--r--addons/escoria-core/game/core-scripts/esc_room.gd6
-rw-r--r--addons/escoria-core/game/core-scripts/esc_terrain.gd14
-rw-r--r--addons/escoria-core/game/core-scripts/esc_tooltip.gd3
-rw-r--r--addons/escoria-core/game/core-scripts/esc_walk_context.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/migrations/esc_migration.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/migrations/esc_migration_manager.gd20
-rw-r--r--addons/escoria-core/game/core-scripts/pre-4.7/.gdignore0
-rw-r--r--addons/escoria-core/game/core-scripts/pre-4.7/esc_item.4.6.gd1434
-rw-r--r--addons/escoria-core/game/core-scripts/pre-4.7/esc_item.4.7.gd1432
-rw-r--r--addons/escoria-core/game/core-scripts/pre-4.7/last_version_used.json1
-rw-r--r--addons/escoria-core/game/core-scripts/resources/esc_animationname.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/resources/esc_animationresource.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/resources/esc_directionangle.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/resources/esc_resource_descriptor.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/save_data/esc_save_manager.gd79
-rw-r--r--addons/escoria-core/game/core-scripts/save_data/esc_savegame.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/save_data/esc_savesettings.gd2
-rw-r--r--addons/escoria-core/game/core-scripts/save_data/esc_settings_manager.gd5
-rw-r--r--addons/escoria-core/game/core-scripts/utils/esc_utils.gd18
-rw-r--r--addons/escoria-core/game/esc_autoload.gd17
-rw-r--r--addons/escoria-core/game/esc_dependency_injector.gd2
-rw-r--r--addons/escoria-core/game/esc_inputs_manager.gd36
-rw-r--r--addons/escoria-core/game/esc_project_settings_manager.gd5
-rw-r--r--addons/escoria-core/game/escoria.gd39
-rw-r--r--addons/escoria-core/game/main.gd122
-rw-r--r--addons/escoria-core/game/main_scene.gd2
-rw-r--r--addons/escoria-core/game/scenes/camera_player/camera.tscn5
-rw-r--r--addons/escoria-core/game/scenes/camera_player/esc_camera.gd23
-rw-r--r--addons/escoria-core/game/scenes/camera_player/esc_camera_limits.gd2
-rw-r--r--addons/escoria-core/game/scenes/dialogs/esc_dialog_manager.gd10
-rw-r--r--addons/escoria-core/game/scenes/dialogs/esc_dialog_options_chooser.gd2
-rw-r--r--addons/escoria-core/game/scenes/dialogs/esc_dialog_player.gd17
-rw-r--r--addons/escoria-core/game/scenes/esc_prompt/esc_prompt_popup.gd22
-rw-r--r--addons/escoria-core/game/scenes/inventory/inventory_ui.gd4
-rw-r--r--addons/escoria-core/game/scenes/sound/esc_ambient_player.gd2
-rw-r--r--addons/escoria-core/game/scenes/sound/esc_music_player.gd2
-rw-r--r--addons/escoria-core/game/scenes/sound/esc_sound_player.gd2
-rw-r--r--addons/escoria-core/game/scenes/sound/esc_speech_player.gd4
-rw-r--r--addons/escoria-core/game/scenes/transitions/esc_transition_player.gd8
-rw-r--r--addons/escoria-core/game/tween3.gd56
-rw-r--r--addons/escoria-core/patterns/state_machine/state.gd2
-rw-r--r--addons/escoria-core/patterns/state_machine/state_machine.gd17
-rw-r--r--addons/escoria-core/plugin.gd22
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd1879
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc3
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc8
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc14
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc9
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc12
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc5
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc.uid0
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc.uid0
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc8
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc.uid0
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc14
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc6
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc5
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc9
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc.uid0
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc6
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_hidden_authored_default_after_filtering.esc10
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_visible_authored_default_after_filtering.esc10
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc11
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc5
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc15
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc17
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc11
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc.uid0
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc6
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc3
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc8
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc11
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc2
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc2
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc10
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc10
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc12
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc6
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc5
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc11
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd16
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd32
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd.uid0
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd15
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/esc_parser_test.gd586
-rw-r--r--addons/escoria-core/testing/ashes/parser/esc_parser_test.gd.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc9
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc11
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc10
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc7
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_right_square.esc9
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc6
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc9
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc9
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/done_partial_match.esc5
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc5
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc5
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc41
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc2
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc6
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_done.esc6
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_if.esc7
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc5
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc5
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc9
-rw-r--r--addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc.uid1
-rw-r--r--addons/escoria-core/testing/esc_new_game_state_test.gd38
-rw-r--r--addons/escoria-core/testing/esc_new_game_state_test.gd.uid1
-rw-r--r--addons/escoria-core/testing/esc_object_manager_test.gd79
-rw-r--r--addons/escoria-core/testing/esc_object_manager_test.gd.uid1
-rw-r--r--addons/escoria-core/testing/issue_946_room_transition_test.gd104
-rw-r--r--addons/escoria-core/testing/issue_946_room_transition_test.gd.uid1
-rw-r--r--addons/escoria-core/testing/player_angles_finder.gd20
-rw-r--r--addons/escoria-core/testing/rtl_screen_offset_testing.gd17
-rw-r--r--addons/escoria-core/tools/logging/esc_logger.gd5
-rw-r--r--addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd14
-rw-r--r--addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd18
-rw-r--r--addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd2
-rw-r--r--addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd2
-rwxr-xr-x[-rw-r--r--]addons/escoria-core/ui_library/fonts/caslonantique.ttfbin147756 -> 147756 bytes
-rw-r--r--addons/escoria-core/ui_library/inventory/esc_inventory_button.gd2
-rw-r--r--addons/escoria-core/ui_library/inventory/esc_inventory_container.gd4
-rw-r--r--addons/escoria-core/ui_library/menus/load_save/save/save_game.gd13
-rw-r--r--addons/escoria-core/ui_library/menus/load_save/save/save_name_popup.gd4
-rw-r--r--addons/escoria-core/ui_library/menus/load_save/save/save_name_popup.tscn67
-rw-r--r--addons/escoria-core/ui_library/menus/options/options.gd36
-rw-r--r--addons/escoria-dialog-simple/chooser/simple.gd30
-rw-r--r--addons/escoria-dialog-simple/esc_dialog_simple_state_machine.gd4
-rwxr-xr-x[-rw-r--r--]addons/escoria-ui-keyboard-9verbs/fonts/caslonantique.ttfbin147756 -> 147756 bytes
-rw-r--r--addons/escoria-ui-keyboard-9verbs/game.gd111
-rw-r--r--addons/escoria-ui-keyboard-9verbs/verbs_menu.gd2
-rw-r--r--addons/escoria-ui-simplemouse/cursors/new/wait_b.pngbin0 -> 371 bytes
-rw-r--r--addons/escoria-ui-simplemouse/cursors/new/wait_b.png.import40
-rwxr-xr-x[-rw-r--r--]addons/escoria-ui-simplemouse/fonts/caslonantique.ttfbin147756 -> 147756 bytes
-rw-r--r--addons/escoria-ui-simplemouse/game.gd77
-rw-r--r--addons/escoria-ui-simplemouse/game.tscn31
-rwxr-xr-x[-rw-r--r--]addons/escoria-ui-simplemouse/images/frame.pngbin285943 -> 285943 bytes
-rwxr-xr-x[-rw-r--r--]addons/escoria-ui-simplemouse/images/inventory_bg.pngbin59720 -> 59720 bytes
-rwxr-xr-x[-rw-r--r--]addons/escoria-ui-simplemouse/images/inventory_icon.pngbin68743 -> 68743 bytes
-rw-r--r--addons/escoria-ui-simplemouse/verbs_mouseicons.gd2
-rw-r--r--addons/escoria-ui-simplemouse/verbs_mouseicons.tscn45
-rw-r--r--addons/escoria-wizard/CharacterCreator.gd17
-rw-r--r--addons/escoria-wizard/CharacterCreator.tscn80
-rw-r--r--addons/escoria-wizard/ItemCreator.tscn139
-rw-r--r--addons/escoria-wizard/RoomCreator.gd115
-rw-r--r--addons/escoria-wizard/RoomCreator.tscn120
-rw-r--r--addons/escoria-wizard/item_creator.gd45
-rw-r--r--addons/gdUnit4/LICENSE21
-rw-r--r--addons/gdUnit4/plugin.cfg7
-rw-r--r--addons/gdUnit4/plugin.gd101
-rw-r--r--addons/gdUnit4/plugin.gd.uid1
-rw-r--r--addons/gdUnit4/runtest.cmd62
-rwxr-xr-xaddons/gdUnit4/runtest.sh62
-rw-r--r--addons/gdUnit4/src/Comparator.gd12
-rw-r--r--addons/gdUnit4/src/Comparator.gd.uid1
-rw-r--r--addons/gdUnit4/src/Fuzzers.gd80
-rw-r--r--addons/gdUnit4/src/Fuzzers.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitArrayAssert.gd122
-rw-r--r--addons/gdUnit4/src/GdUnitArrayAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitAssert.gd47
-rw-r--r--addons/gdUnit4/src/GdUnitAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitAwaiter.gd72
-rw-r--r--addons/gdUnit4/src/GdUnitAwaiter.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitBoolAssert.gd35
-rw-r--r--addons/gdUnit4/src/GdUnitBoolAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitConstants.gd10
-rw-r--r--addons/gdUnit4/src/GdUnitConstants.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitDictionaryAssert.gd79
-rw-r--r--addons/gdUnit4/src/GdUnitDictionaryAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitFailureAssert.gd52
-rw-r--r--addons/gdUnit4/src/GdUnitFailureAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitFileAssert.gd38
-rw-r--r--addons/gdUnit4/src/GdUnitFileAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitFloatAssert.gd75
-rw-r--r--addons/gdUnit4/src/GdUnitFloatAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitFuncAssert.gd42
-rw-r--r--addons/gdUnit4/src/GdUnitFuncAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitGodotErrorAssert.gd59
-rw-r--r--addons/gdUnit4/src/GdUnitGodotErrorAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitIntAssert.gd79
-rw-r--r--addons/gdUnit4/src/GdUnitIntAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitObjectAssert.gd51
-rw-r--r--addons/gdUnit4/src/GdUnitObjectAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitResultAssert.gd51
-rw-r--r--addons/gdUnit4/src/GdUnitResultAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitSceneRunner.gd350
-rw-r--r--addons/gdUnit4/src/GdUnitSceneRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitSignalAssert.gd158
-rw-r--r--addons/gdUnit4/src/GdUnitSignalAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitStringAssert.gd71
-rw-r--r--addons/gdUnit4/src/GdUnitStringAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitTestSuite.gd746
-rw-r--r--addons/gdUnit4/src/GdUnitTestSuite.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitTuple.gd86
-rw-r--r--addons/gdUnit4/src/GdUnitTuple.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitValueExtractor.gd9
-rw-r--r--addons/gdUnit4/src/GdUnitValueExtractor.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitVectorAssert.gd55
-rw-r--r--addons/gdUnit4/src/GdUnitVectorAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/CallBackValueProvider.gd25
-rw-r--r--addons/gdUnit4/src/asserts/CallBackValueProvider.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/DefaultValueProvider.gd13
-rw-r--r--addons/gdUnit4/src/asserts/DefaultValueProvider.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdAssertMessages.gd752
-rw-r--r--addons/gdUnit4/src/asserts/GdAssertMessages.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdAssertReports.gd54
-rw-r--r--addons/gdUnit4/src/asserts/GdAssertReports.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd424
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd80
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitAssertions.gd68
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd87
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd207
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd136
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd116
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd159
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd177
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd151
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd166
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd166
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd128
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd172
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd208
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd187
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/ValueProvider.gd10
-rw-r--r--addons/gdUnit4/src/asserts/ValueProvider.gd.uid1
-rw-r--r--addons/gdUnit4/src/cmd/CmdArgumentParser.gd62
-rw-r--r--addons/gdUnit4/src/cmd/CmdArgumentParser.gd.uid1
-rw-r--r--addons/gdUnit4/src/cmd/CmdCommand.gd27
-rw-r--r--addons/gdUnit4/src/cmd/CmdCommand.gd.uid1
-rw-r--r--addons/gdUnit4/src/cmd/CmdCommandHandler.gd136
-rw-r--r--addons/gdUnit4/src/cmd/CmdCommandHandler.gd.uid1
-rw-r--r--addons/gdUnit4/src/cmd/CmdOption.gd61
-rw-r--r--addons/gdUnit4/src/cmd/CmdOption.gd.uid1
-rw-r--r--addons/gdUnit4/src/cmd/CmdOptions.gd31
-rw-r--r--addons/gdUnit4/src/cmd/CmdOptions.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdArrayTools.gd127
-rw-r--r--addons/gdUnit4/src/core/GdArrayTools.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdDiffTool.gd224
-rw-r--r--addons/gdUnit4/src/core/GdDiffTool.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdObjects.gd726
-rw-r--r--addons/gdUnit4/src/core/GdObjects.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnit4Version.gd65
-rw-r--r--addons/gdUnit4/src/core/GdUnit4Version.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitFileAccess.gd232
-rw-r--r--addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitProperty.gd81
-rw-r--r--addons/gdUnit4/src/core/GdUnitProperty.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitResult.gd109
-rw-r--r--addons/gdUnit4/src/core/GdUnitResult.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitRunnerConfig.gd139
-rw-r--r--addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd642
-rw-r--r--addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSettings.gd443
-rw-r--r--addons/gdUnit4/src/core/GdUnitSettings.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd81
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignalCollector.gd129
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignals.gd118
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignals.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSingleton.gd56
-rw-r--r--addons/gdUnit4/src/core/GdUnitSingleton.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd97
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd20
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd410
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitTools.gd144
-rw-r--r--addons/gdUnit4/src/core/GdUnitTools.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GodotVersionFixures.gd11
-rw-r--r--addons/gdUnit4/src/core/GodotVersionFixures.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/LocalTime.gd114
-rw-r--r--addons/gdUnit4/src/core/LocalTime.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/_TestCase.gd253
-rw-r--r--addons/gdUnit4/src/core/_TestCase.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/assets/touch-button.pngbin0 -> 3292 bytes
-rw-r--r--addons/gdUnit4/src/core/assets/touch-button.png.import40
-rw-r--r--addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd76
-rw-r--r--addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd64
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd42
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd17
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd15
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd126
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd27
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd57
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd26
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd26
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd25
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd22
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd56
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd41
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd14
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd14
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd21
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd124
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitShortcut.gd73
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitGUID.gd46
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd125
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd323
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd13
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd171
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEvent.gd207
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventInit.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventStop.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd19
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitSessionClose.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitSessionStart.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd274
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd135
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd77
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd56
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd22
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd20
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd37
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd29
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd14
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd147
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd37
-rw-r--r--addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd52
-rw-r--r--addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd55
-rw-r--r--addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd53
-rw-r--r--addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd11
-rw-r--r--addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd78
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd9
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd111
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd191
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd11
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdClassDescriptor.gd25
-rw-r--r--addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd290
-rw-r--r--addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionArgument.gd208
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd286
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd188
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdScriptParser.gd764
-rw-r--r--addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd74
-rw-r--r--addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd163
-rw-r--r--addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/report/GdUnitReport.gd84
-rw-r--r--addons/gdUnit4/src/core/report/GdUnitReport.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd470
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd90
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn34
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSession.gd169
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd180
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd36
-rw-r--r--addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd144
-rw-r--r--addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd70
-rw-r--r--addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd69
-rw-r--r--addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd227
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd214
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd115
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs235
-rw-r--r--addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd114
-rw-r--r--addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/CallableDoubler.gd156
-rw-r--r--addons/gdUnit4/src/doubler/CallableDoubler.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdFunctionDoubler.gd4
-rw-r--r--addons/gdUnit4/src/doubler/GdFunctionDoubler.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd119
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd336
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd10
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd53
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd84
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd8
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd.uid1
-rw-r--r--addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd73
-rw-r--r--addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/BoolFuzzer.gd24
-rw-r--r--addons/gdUnit4/src/fuzzers/BoolFuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/FloatFuzzer.gd37
-rw-r--r--addons/gdUnit4/src/fuzzers/FloatFuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/Fuzzer.gd80
-rw-r--r--addons/gdUnit4/src/fuzzers/Fuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/IntFuzzer.gd77
-rw-r--r--addons/gdUnit4/src/fuzzers/IntFuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/StringFuzzer.gd112
-rw-r--r--addons/gdUnit4/src/fuzzers/StringFuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd45
-rw-r--r--addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd48
-rw-r--r--addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd11
-rw-r--r--addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd50
-rw-r--r--addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd32
-rw-r--r--addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd22
-rw-r--r--addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd22
-rw-r--r--addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd13
-rw-r--r--addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd42
-rw-r--r--addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd.uid1
-rw-r--r--addons/gdUnit4/src/mocking/GdUnitMock.gd45
-rw-r--r--addons/gdUnit4/src/mocking/GdUnitMock.gd.uid1
-rw-r--r--addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd186
-rw-r--r--addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid1
-rw-r--r--addons/gdUnit4/src/mocking/GdUnitMockImpl.gd143
-rw-r--r--addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/monitor/ErrorLogEntry.gd41
-rw-r--r--addons/gdUnit4/src/monitor/ErrorLogEntry.gd.uid1
-rw-r--r--addons/gdUnit4/src/monitor/GdUnitMonitor.gd24
-rw-r--r--addons/gdUnit4/src/monitor/GdUnitMonitor.gd.uid1
-rw-r--r--addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd70
-rw-r--r--addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd.uid1
-rw-r--r--addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd221
-rw-r--r--addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid1
-rw-r--r--addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd122
-rw-r--r--addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/GdUnitServer.gd41
-rw-r--r--addons/gdUnit4/src/network/GdUnitServer.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/GdUnitServer.tscn10
-rw-r--r--addons/gdUnit4/src/network/GdUnitServerConstants.gd6
-rw-r--r--addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/GdUnitTask.gd25
-rw-r--r--addons/gdUnit4/src/network/GdUnitTask.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/GdUnitTcpClient.gd126
-rw-r--r--addons/gdUnit4/src/network/GdUnitTcpClient.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/GdUnitTcpNode.gd73
-rw-r--r--addons/gdUnit4/src/network/GdUnitTcpNode.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/GdUnitTcpServer.gd140
-rw-r--r--addons/gdUnit4/src/network/GdUnitTcpServer.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/rpc/RPC.gd37
-rw-r--r--addons/gdUnit4/src/network/rpc/RPC.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCClientConnect.gd13
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCClientConnect.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd13
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd14
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCMessage.gd18
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCMessage.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitReportSummary.gd202
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitReportWriter.gd12
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd47
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitTestReporter.gd112
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd96
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd235
-rw-r--r--addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd60
-rw-r--r--addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd199
-rw-r--r--addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd72
-rw-r--r--addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/html/template/.gdignore0
-rw-r--r--addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css66
-rw-r--r--addons/gdUnit4/src/reporters/html/template/css/logo.pngbin0 -> 49775 bytes
-rw-r--r--addons/gdUnit4/src/reporters/html/template/css/styles.css475
-rw-r--r--addons/gdUnit4/src/reporters/html/template/folder_report.html122
-rw-r--r--addons/gdUnit4/src/reporters/html/template/index.html164
-rw-r--r--addons/gdUnit4/src/reporters/html/template/suite_report.html177
-rw-r--r--addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd143
-rw-r--r--addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/xml/XmlElement.gd69
-rw-r--r--addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid1
-rw-r--r--addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd154
-rw-r--r--addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd.uid1
-rw-r--r--addons/gdUnit4/src/spy/GdUnitSpyImpl.gd46
-rw-r--r--addons/gdUnit4/src/spy/GdUnitSpyImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/GdUnitConsole.gd91
-rw-r--r--addons/gdUnit4/src/ui/GdUnitConsole.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/GdUnitConsole.tscn64
-rw-r--r--addons/gdUnit4/src/ui/GdUnitFonts.gd36
-rw-r--r--addons/gdUnit4/src/ui/GdUnitFonts.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/GdUnitInspector.gd34
-rw-r--r--addons/gdUnit4/src/ui/GdUnitInspector.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/GdUnitInspector.tscn70
-rw-r--r--addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd31
-rw-r--r--addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/GdUnitUiTools.gd151
-rw-r--r--addons/gdUnit4/src/ui/GdUnitUiTools.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/ScriptEditorControls.gd100
-rw-r--r--addons/gdUnit4/src/ui/ScriptEditorControls.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd45
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd37
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd63
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.tscn70
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd41
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorMonitor.gd54
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorMonitor.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorMonitor.tscn94
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd49
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorProgressBar.tscn33
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd209
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorStatusBar.tscn465
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorToolBar.gd104
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorToolBar.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorToolBar.tscn202
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd1210
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorTreePanel.tscn240
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd56
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn36
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd327
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn349
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd255
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn148
-rw-r--r--addons/gdUnit4/src/ui/settings/logo.pngbin0 -> 49775 bytes
-rw-r--r--addons/gdUnit4/src/ui/settings/logo.png.import40
-rw-r--r--addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd129
-rw-r--r--addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/templates/TestSuiteTemplate.tscn127
-rw-r--r--addons/gdUnit4/src/update/GdMarkDownReader.gd405
-rw-r--r--addons/gdUnit4/src/update/GdMarkDownReader.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitPatch.gd20
-rw-r--r--addons/gdUnit4/src/update/GdUnitPatch.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitPatcher.gd75
-rw-r--r--addons/gdUnit4/src/update/GdUnitPatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdate.gd305
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdate.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdate.tscn100
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateClient.gd98
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateNotify.gd206
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn97
-rw-r--r--addons/gdUnit4/src/update/assets/border_bottom.pngbin0 -> 1757 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/border_bottom.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/border_top.pngbin0 -> 1749 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/border_top.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/dot1.pngbin0 -> 730 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/dot1.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/dot2.pngbin0 -> 883 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/dot2.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/embedded.pngbin0 -> 287 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/embedded.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/horizontal-line2.pngbin0 -> 332 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/horizontal-line2.png.import40
830 files changed, 39024 insertions, 1330 deletions
diff --git a/addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.gd b/addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.gd
new file mode 100644
index 0000000..d89519d
--- /dev/null
+++ b/addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.gd
@@ -0,0 +1,80 @@
+@tool
+class_name AcceptDialogMigrate4647
+extends Window
+
+signal confirmed
+signal canceled
+
+var migrator: Migrator4647
+var required_migration: bool = false
+
+@onready var label: Label = $FlowContainer/Label
+@onready var mark_this_as_done: CheckButton = $FlowContainer/mark_this_as_done
+@onready var confirm_button: Button = $FlowContainer/HBoxContainer/confirm
+@onready var cancel_button: Button = $FlowContainer/HBoxContainer/cancel
+
+
+func _ready() -> void:
+ migrator = Migrator4647.new()
+ var action: Migrator4647.MigrationAction = migrator.check_need_upgrade_or_downgrade_scripts()
+ match action:
+ Migrator4647.MigrationAction.UPGRADE_4_7:
+ title = "Auto-upgrade Escoria-core scripts for Godot Engine 4.7.x"
+ label.text = \
+"""The version of Godot Engine you appear to be running is newer
+than the one this version of Escoria relies on (4.6.x) or this is the first time
+you run this version of Escoria.\n
+Godot Engine 4.6 and 4.7 APIs have some differences in methods that Escoria makes use of.
+Escoria can now automatically revert its impacted core scripts to makes them compatible
+with Godot Engine 4.7.\n
+Here is the list of the impacted files:
+- res://addons/escoria-core/game/core-scripts/esc_item.gd\n
+⚠️IMPORTANT⚠️: the editor will restart after this action is done.\n
+If you wish to let Escoria proceed, choose 'OK'.
+If you wish to upgrade to a newer version of Godot, choose 'Cancel'.
+If you have edited these files for your own needs, you'll need to manually fix them:
+choose 'Cancel' (see README; existing files will be backuped anyway)."""
+ required_migration = true
+ Migrator4647.MigrationAction.DOWNGRADE_4_6:
+ title = "Auto-downgrade Escoria-core scripts for Godot Engine 4.6.x"
+ label.text = \
+"""The version of Godot Engine you appear to be running is older
+than the one this version of Escoria relies on (4.7.x) or this is the first time
+you run this version of Escoria.\n
+Godot Engine 4.6 and 4.7 APIs have some differences in methods that Escoria makes use of.
+Escoria can now automatically revert its impacted core scripts to makes them compatible
+with Godot Engine 4.6.\n
+Here is the list of the impacted files:
+- res://addons/escoria-core/game/core-scripts/esc_item.gd\n
+⚠️IMPORTANT⚠️: the editor will restart after this action is done.\n
+If you wish to let Escoria proceed, choose 'OK' (a backup of the existing file will be created).
+If you wish to upgrade to a newer version of Godot, choose 'Cancel' (nothing will be done,
+but this tool will happen next time you open the editor).
+If you have edited these files for your own needs, you'll need to manually fix them:
+choose 'Cancel' (see README; existing files will be backuped anyway)."""
+ required_migration = true
+
+ _:
+ required_migration = false
+
+
+func _on_confirm_pressed() -> void:
+ if mark_this_as_done.button_pressed:
+ await migrator.save_migration_file($FlowContainer/mark_this_as_done.button_pressed)
+ else:
+ migrator.prepare_specific_godot_version(Engine.get_version_info().hex)
+ confirmed.emit()
+ hide()
+
+
+func _on_cancel_pressed() -> void:
+ await migrator.save_migration_file($FlowContainer/mark_this_as_done.button_pressed)
+ canceled.emit()
+ hide()
+
+
+func _on_mark_this_as_done_toggled(toggled_on: bool) -> void:
+ if toggled_on:
+ cancel_button.hide()
+ else:
+ cancel_button.show()
diff --git a/addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.gd.uid b/addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.gd.uid
new file mode 100644
index 0000000..2cf55d3
--- /dev/null
+++ b/addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.gd.uid
@@ -0,0 +1 @@
+uid://cxx10dv5352wk
diff --git a/addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.tscn b/addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.tscn
new file mode 100644
index 0000000..128c980
--- /dev/null
+++ b/addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.tscn
@@ -0,0 +1,57 @@
+[gd_scene format=3 uid="uid://ydte1xndp6p1"]
+
+[ext_resource type="Script" uid="uid://cxx10dv5352wk" path="res://addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.gd" id="1_ogeh4"]
+
+[node name="AcceptDialogMigrate4647" type="Window" unique_id=1453257784]
+oversampling_override = 1.0
+position = Vector2i(0, 36)
+size = Vector2i(720, 530)
+script = ExtResource("1_ogeh4")
+
+[node name="FlowContainer" type="VBoxContainer" parent="." unique_id=726240657]
+offset_left = 8.0
+offset_top = 8.0
+offset_right = 712.0
+offset_bottom = 517.0
+
+[node name="Label" type="Label" parent="FlowContainer" unique_id=1668065635]
+layout_mode = 2
+text = "The version of Godot Engine you appear to be running is older
+ than the one this version of Escoria relies on (4.7.x) or this is the first time
+ you run this version of Escoria.
+
+ Godot Engine 4.6 and 4.7 APIs have some differences in methods that Escoria makes use of.
+ Escoria can now automatically revert its impacted core scripts to makes them compatible
+ with Godot Engine 4.6.
+
+ Here is the list of the impacted files:
+ - res://addons/escoria-core/game/core-scripts/esc_item.gd
+
+ ⚠️IMPORTANT⚠️: the editor will restart after this action is done.
+
+ If you wish to let Escoria proceed, choose 'OK'.
+ If you wish to upgrade to a newer version of Godot, choose 'Cancel'.
+ If you have edited these files for your own needs, you'll need to manually fix them:
+ choose 'Cancel' (see README; existing files will be backuped anyway)."
+autowrap_mode = 3
+
+[node name="mark_this_as_done" type="CheckButton" parent="FlowContainer" unique_id=99402283]
+layout_mode = 2
+text = "Mark this as already done"
+
+[node name="HBoxContainer" type="HBoxContainer" parent="FlowContainer" unique_id=1123299495]
+layout_mode = 2
+theme_override_constants/separation = 55
+alignment = 1
+
+[node name="confirm" type="Button" parent="FlowContainer/HBoxContainer" unique_id=1563226882]
+layout_mode = 2
+text = "Ok"
+
+[node name="cancel" type="Button" parent="FlowContainer/HBoxContainer" unique_id=2075346076]
+layout_mode = 2
+text = "Cancel"
+
+[connection signal="toggled" from="FlowContainer/mark_this_as_done" to="." method="_on_mark_this_as_done_toggled"]
+[connection signal="pressed" from="FlowContainer/HBoxContainer/confirm" to="." method="_on_confirm_pressed"]
+[connection signal="pressed" from="FlowContainer/HBoxContainer/cancel" to="." method="_on_cancel_pressed"]
diff --git a/addons/escoria-core/editor/migration_4_6_4_7/migrator_4_6_4_7.gd b/addons/escoria-core/editor/migration_4_6_4_7/migrator_4_6_4_7.gd
new file mode 100644
index 0000000..45e480a
--- /dev/null
+++ b/addons/escoria-core/editor/migration_4_6_4_7/migrator_4_6_4_7.gd
@@ -0,0 +1,107 @@
+class_name Migrator4647
+extends Node
+
+enum MigrationAction {
+ UPGRADE_4_7,
+ DOWNGRADE_4_6,
+ DO_NOTHING
+}
+
+const MIGRATION_DONE = "migration_done"
+const GODOT_4_7_HEX = 0x040700
+
+
+func check_need_upgrade_or_downgrade_scripts(engine_version: Dictionary = Engine.get_version_info()) -> MigrationAction:
+ var data: Dictionary = read_migration_file()
+ var current_version_hex: int = engine_version["hex"]
+ var current_is_4_7_or_newer: bool = current_version_hex >= GODOT_4_7_HEX
+ if data.is_empty(): # No existing file : initiate migration
+ if current_is_4_7_or_newer:
+ return MigrationAction.UPGRADE_4_7
+ return MigrationAction.DOWNGRADE_4_6
+
+ if not data[MIGRATION_DONE]:
+ if data["last_engine_version_hex"] >= GODOT_4_7_HEX:
+ return MigrationAction.UPGRADE_4_7
+ return MigrationAction.DOWNGRADE_4_6
+
+ var last_migration_version = data["last_engine_version_hex"]
+ var last_is_4_7_or_newer: bool = last_migration_version >= GODOT_4_7_HEX
+ if last_is_4_7_or_newer == current_is_4_7_or_newer:
+ return MigrationAction.DO_NOTHING
+ if last_is_4_7_or_newer:
+ return MigrationAction.DOWNGRADE_4_6
+ return MigrationAction.UPGRADE_4_7
+
+
+func prepare_specific_godot_version(target_hex: int) -> void:
+ var dir = DirAccess.open("res://addons/escoria-core/game/core-scripts/")
+ print("Preparing Escoria for Godot version %s" % [target_hex])
+ if dir == null:
+ print("Failed opening folder res://addons/escoria-core/game/core-scripts/")
+ return
+
+ # Backup existing files, just in case they were edited by gamedev (and so, different from
+ # origin).
+ var datetime: String = Time.get_datetime_string_from_system().replace(":", ".")
+ var res: Error = dir.copy(
+ "res://addons/escoria-core/game/core-scripts/esc_item.gd",
+ "res://addons/escoria-core/game/core-scripts/pre-4.7/esc_item.gd.%s.bak" % datetime
+ )
+ if res != OK:
+ print("Failed backup of existing file res://addons/escoria-core/game/core-scripts/esc_item.gd")
+ return
+
+ # Then remove
+ res = dir.remove("res://addons/escoria-core/game/core-scripts/esc_item.gd")
+ if res != OK:
+ print("Failed removing file res://addons/escoria-core/game/core-scripts/esc_item.gd")
+ return
+
+ if target_hex < GODOT_4_7_HEX: # < 4.7.0
+ # Copy 4.6 files
+ res = DirAccess.copy_absolute(
+ "res://addons/escoria-core/game/core-scripts/pre-4.7/esc_item.4.6.gd",
+ "res://addons/escoria-core/game/core-scripts/esc_item.gd"
+ )
+ if res != OK:
+ print("Failed copying file res://addons/escoria-core/game/core-scripts/pre-4.7/esc_item.4.6.gd")
+ return
+ else: # >= 4.7.x
+ # Copy 4.7 files
+ res = DirAccess.copy_absolute(
+ "res://addons/escoria-core/game/core-scripts/pre-4.7/esc_item.4.7.gd",
+ "res://addons/escoria-core/game/core-scripts/esc_item.gd"
+ )
+ if res != OK:
+ print("Failed copying file res://addons/escoria-core/game/core-scripts/pre-4.7/esc_item.4.7.gd")
+ return
+
+ # Lastly, store the last used engine version in a file
+ save_migration_file(true)
+
+ EditorInterface.restart_editor()
+
+
+func save_migration_file(migration_done: bool):
+ var new_last_version_used_file = FileAccess.open("res://addons/escoria-core/game/core-scripts/pre-4.7/last_version_used.json", FileAccess.WRITE)
+ var version = Engine.get_version_info()
+ var data: Dictionary = {
+ "last_engine_version": version.string,
+ "last_engine_version_hex": version.hex,
+ "migration_done": migration_done
+ }
+ new_last_version_used_file.store_string(JSON.stringify(data))
+ new_last_version_used_file.close()
+
+
+func read_migration_file() -> Dictionary:
+ var new_last_version_used_file = FileAccess.open("res://addons/escoria-core/game/core-scripts/pre-4.7/last_version_used.json", FileAccess.READ)
+ if new_last_version_used_file == null:
+ return {}
+ var parsed: Variant = JSON.parse_string(new_last_version_used_file.get_as_text())
+ new_last_version_used_file.close()
+ if not (parsed is Dictionary):
+ push_error("Error reading file res://addons/escoria-core/game/core-scripts/pre-4.7/last_version_used.json")
+ return {}
+ return parsed
diff --git a/addons/escoria-core/editor/migration_4_6_4_7/migrator_4_6_4_7.gd.uid b/addons/escoria-core/editor/migration_4_6_4_7/migrator_4_6_4_7.gd.uid
new file mode 100644
index 0000000..e2eb44a
--- /dev/null
+++ b/addons/escoria-core/editor/migration_4_6_4_7/migrator_4_6_4_7.gd.uid
@@ -0,0 +1 @@
+uid://rdo2ddvl7csi
diff --git a/addons/escoria-core/game/core-scripts/behaviors/esc_movable.gd b/addons/escoria-core/game/core-scripts/behaviors/esc_movable.gd
index 27ed7a5..aae7837 100644
--- a/addons/escoria-core/game/core-scripts/behaviors/esc_movable.gd
+++ b/addons/escoria-core/game/core-scripts/behaviors/esc_movable.gd
@@ -1,7 +1,7 @@
## Node that performs the moving (walk, teleport, terrain scaling...) actions on
## its parent node.
-extends Node
class_name ESCMovable
+extends Node
## Tasks carried out by this walkable node[br]
@@ -623,5 +623,4 @@ func get_shortest_way_to_dir(current_dir: int, target_dir: int) -> int:
if internal and current_dir < target_dir or \
(not internal and current_dir > target_dir):
return 1
- else:
- return -1
+ return -1
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/accept_input.gd b/addons/escoria-core/game/core-scripts/esc/commands/accept_input.gd
index f29f9c7..18a6228 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/accept_input.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/accept_input.gd
@@ -10,8 +10,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name AcceptInputCommand
+extends ESCBaseCommand
## The list of supported input types
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/anim.gd b/addons/escoria-core/game/core-scripts/esc/commands/anim.gd
index 6525654..5711707 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/anim.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/anim.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name AnimCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/anim_block.gd b/addons/escoria-core/game/core-scripts/esc/commands/anim_block.gd
index b64b166..f564a94 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/anim_block.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/anim_block.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name AnimBlockCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/block_say.gd b/addons/escoria-core/game/core-scripts/esc/commands/block_say.gd
index d2b7dd2..6ebb0c1 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/block_say.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/block_say.gd
@@ -8,8 +8,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name BlockSayCommand
+extends ESCBaseCommand
## Constructor[br]
@@ -48,7 +48,7 @@ func configure() -> ESCCommandArgumentDescriptor:
## #### Returns[br]
## [br]
## Returns True if the arguments are valid, false otherwise. (`bool`)
-func validate(arguments: Array):
+func validate(_arguments: Array):
return true
@@ -63,7 +63,7 @@ func validate(arguments: Array):
## #### Returns[br]
## [br]
## Returns the execution result code. (`int`)
-func run(command_params: Array) -> int:
+func run(_command_params: Array) -> int:
escoria.dialog_player.enable_preserve_dialog_box()
return ESCExecution.RC_OK
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_push.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_push.gd
index 37f99dd..710a195 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_push.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_push.gd
@@ -26,8 +26,8 @@
##
## @ASHES
## @COMMAND
-extends ESCCameraBaseCommand
class_name CameraPushCommand
+extends ESCCameraBaseCommand
## The list of supported transitions as per the link mentioned above
const SUPPORTED_TRANSITIONS = ["LINEAR","SINE","QUINT","QUART","QUAD" ,"EXPO","ELASTIC","CUBIC",
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_push_block.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_push_block.gd
index 289cfc7..52db676 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_push_block.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_push_block.gd
@@ -26,8 +26,8 @@
##
## @ASHES
## @COMMAND
-extends ESCCameraBaseCommand
class_name CameraPushBlockCommand
+extends ESCCameraBaseCommand
## The list of supported transitions as per the link mentioned above
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_limits.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_limits.gd
index 79dc992..b468e7d 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_limits.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_limits.gd
@@ -15,8 +15,8 @@
##
## @ASHES
## @COMMAND
-extends ESCCameraBaseCommand
class_name CameraSetLimitsCommand
+extends ESCCameraBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_pos.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_pos.gd
index 5403bcf..365b668 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_pos.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_pos.gd
@@ -12,8 +12,8 @@
##
## @ASHES
## @COMMAND
-extends ESCCameraBaseCommand
class_name CameraSetPosCommand
+extends ESCCameraBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_pos_block.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_pos_block.gd
index 2fe7dc0..8833c24 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_pos_block.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_pos_block.gd
@@ -15,8 +15,8 @@
##
## @ASHES
## @COMMAND
-extends ESCCameraBaseCommand
class_name CameraSetPosBlockCommand
+extends ESCCameraBaseCommand
# Tween for blocking
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_target.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_target.gd
index 8d5e3e0..485dd28 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_target.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_target.gd
@@ -13,8 +13,8 @@
##
## @ASHES
## @COMMAND
-extends ESCCameraBaseCommand
class_name CameraSetTargetCommand
+extends ESCCameraBaseCommand
## Provides the argument descriptor that defines the command signature.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_target_block.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_target_block.gd
index e071dec..07841fd 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_target_block.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_target_block.gd
@@ -17,8 +17,8 @@
##
## @ASHES
## @COMMAND
-extends ESCCameraBaseCommand
class_name CameraSetTargetBlockCommand
+extends ESCCameraBaseCommand
# Tween for blocking
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom.gd
index 515aa44..4be55ee 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom.gd
@@ -16,8 +16,8 @@
##
## @ASHES
## @COMMAND
-extends ESCCameraBaseCommand
class_name CameraSetZoomCommand
+extends ESCCameraBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_block.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_block.gd
index 07ea05f..4ae84b8 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_block.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_block.gd
@@ -20,8 +20,8 @@
##
## @ASHES
## @COMMAND
-extends ESCCameraBaseCommand
class_name CameraSetZoomBlockCommand
+extends ESCCameraBaseCommand
var _camera_tween: Tween3
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_height.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_height.gd
index 5ffa7c0..dba9ad5 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_height.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_height.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name CameraSetZoomHeightCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_height_block.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_height_block.gd
index b526d9c..429be97 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_height_block.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_set_zoom_height_block.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name CameraSetZoomHeightBlockCommand
+extends ESCBaseCommand
## Tween for blocking
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_shift.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_shift.gd
index fb95bb0..379b4f3 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_shift.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_shift.gd
@@ -20,8 +20,8 @@
##
## @ASHES
## @COMMAND
-extends ESCCameraBaseCommand
class_name CameraShiftCommand
+extends ESCCameraBaseCommand
## The list of supported transitions as per the link mentioned above
const SUPPORTED_TRANSITIONS = ["LINEAR","SINE","QUINT","QUART","QUAD" ,"EXPO","ELASTIC","CUBIC",
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/camera_shift_block.gd b/addons/escoria-core/game/core-scripts/esc/commands/camera_shift_block.gd
index a06a8db..c7294a3 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/camera_shift_block.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/camera_shift_block.gd
@@ -23,8 +23,8 @@
##
## @ASHES
## @COMMAND
-extends ESCCameraBaseCommand
class_name CameraShiftBlockCommand
+extends ESCCameraBaseCommand
# The list of supported transitions as per the link mentioned above
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/change_scene.gd b/addons/escoria-core/game/core-scripts/esc/commands/change_scene.gd
index 2ae6e23..b1ff671 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/change_scene.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/change_scene.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name ChangeSceneCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/change_scene_godot.gd b/addons/escoria-core/game/core-scripts/esc/commands/change_scene_godot.gd
index 9be93a8..e7b8b70 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/change_scene_godot.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/change_scene_godot.gd
@@ -10,8 +10,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name ChangeSceneGodotCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/custom.gd b/addons/escoria-core/game/core-scripts/esc/commands/custom.gd
index afacce8..c2fe35f 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/custom.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/custom.gd
@@ -13,8 +13,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name CustomCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
@@ -54,7 +54,7 @@ func validate(arguments: Array):
if not escoria.object_manager.has(arguments[0]):
raise_invalid_object_error(self, arguments[0])
return false
- elif not escoria.object_manager.get_object(arguments[0]).node.has_node(
+ if not escoria.object_manager.get_object(arguments[0]).node.has_node(
arguments[1]
):
raise_error(self, "Invalid node. Object with global id %s has no child node called %s."
@@ -63,7 +63,7 @@ func validate(arguments: Array):
arguments[1],
])
return false
- elif not escoria.object_manager.get_object(arguments[0]).node\
+ if not escoria.object_manager.get_object(arguments[0]).node\
.get_node(
arguments[1]
)\
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/dec_global.gd b/addons/escoria-core/game/core-scripts/esc/commands/dec_global.gd
index 8f91433..33aa173 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/dec_global.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/dec_global.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name DecGlobalCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/enable_terrain.gd b/addons/escoria-core/game/core-scripts/esc/commands/enable_terrain.gd
index 106c297..76b3fe9 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/enable_terrain.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/enable_terrain.gd
@@ -10,8 +10,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name EnableTerrainCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
@@ -52,9 +52,8 @@ func run(command_params: Array) -> int:
new_active_navigation_instance
escoria.room_terrain.current_active_navigation_instance.enabled = true
return ESCExecution.RC_OK
- else:
- raise_error(self, "Can not find terrain node. Terrain node %s could not be found." % name)
- return ESCExecution.RC_ERROR
+ raise_error(self, "Can not find terrain node. Terrain node %s could not be found." % name)
+ return ESCExecution.RC_ERROR
## Function called when the command is interrupted.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/end_block_say.gd b/addons/escoria-core/game/core-scripts/esc/commands/end_block_say.gd
index 9a68f4f..d940ce9 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/end_block_say.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/end_block_say.gd
@@ -8,8 +8,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name EndBlockSayCommand
+extends ESCBaseCommand
## Constructor (bypasses the default constructor to avoid)[br]
@@ -49,7 +49,7 @@ func configure() -> ESCCommandArgumentDescriptor:
## #### Returns[br]
## [br]
## Returns True if the arguments are valid, false otherwise. (`bool`)
-func validate(arguments: Array):
+func validate(_arguments: Array):
return true
@@ -64,7 +64,7 @@ func validate(arguments: Array):
## #### Returns[br]
## [br]
## Returns the execution result code. (`int`)
-func run(command_params: Array) -> int:
+func run(_command_params: Array) -> int:
escoria.dialog_player.disable_preserve_dialog_box()
return ESCExecution.RC_OK
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/hide_menu.gd b/addons/escoria-core/game/core-scripts/esc/commands/hide_menu.gd
index 65567d7..da23186 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/hide_menu.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/hide_menu.gd
@@ -10,8 +10,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name HideMenuCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
@@ -69,7 +69,7 @@ func run(command_params: Array) -> int:
# Transition out from menu
transition_id = escoria.main.scene_transition.transition(
"",
- ESCTransitionPlayer.TRANSITION_MODE.OUT
+ ESCTransitionPlayer.TransitionMode.OUT
)
if transition_id != ESCTransitionPlayer.TRANSITION_ID_INSTANT:
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/inc_global.gd b/addons/escoria-core/game/core-scripts/esc/commands/inc_global.gd
index 2bacd61..2452dcc 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/inc_global.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/inc_global.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name IncGlobalCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/inventory_add.gd b/addons/escoria-core/game/core-scripts/esc/commands/inventory_add.gd
index d715dea..ba59d17 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/inventory_add.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/inventory_add.gd
@@ -10,8 +10,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name InventoryAddCommand
+extends ESCBaseCommand
## List of illegal strings that cannot be used in item names.
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/inventory_remove.gd b/addons/escoria-core/game/core-scripts/esc/commands/inventory_remove.gd
index bd45f66..93e4bab 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/inventory_remove.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/inventory_remove.gd
@@ -10,8 +10,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name InventoryRemoveCommand
+extends ESCBaseCommand
## List of illegal strings that cannot be used in item names.
const ILLEGAL_STRINGS = ["/"]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/play_snd.gd b/addons/escoria-core/game/core-scripts/esc/commands/play_snd.gd
index 90dcc94..41321d8 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/play_snd.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/play_snd.gd
@@ -12,14 +12,27 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name PlaySndCommand
+extends ESCBaseCommand
## The specified sound player
var _snd_player: String
+## Constructor of this command.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _init() -> void:
+ run_in_ready_allowed_during_savegame_loading = false
+
+
## The descriptor of the arguments of this command.[br]
## [br]
## #### Parameters[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/print_internal.gd b/addons/escoria-core/game/core-scripts/esc/commands/print_internal.gd
index 56a601f..418cc72 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/print_internal.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/print_internal.gd
@@ -10,8 +10,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name PrintCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/queue_event.gd b/addons/escoria-core/game/core-scripts/esc/commands/queue_event.gd
index f676fda..bad3a19 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/queue_event.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/queue_event.gd
@@ -13,8 +13,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name QueueEventCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
@@ -64,7 +64,7 @@ func validate(arguments: Array):
var esc_script = escoria.esc_compiler.load_esc_file(node.esc_script)
- if not arguments[1] in esc_script.events:
+ if not esc_script.has_event_with_target(arguments[1]):
raise_error(
self,
"Event with name '%s' not found." % arguments[1]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/queue_resource.gd b/addons/escoria-core/game/core-scripts/esc/commands/queue_resource.gd
index 845ae1c..c0598d2 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/queue_resource.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/queue_resource.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name QueueResourceCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/rand_global.gd b/addons/escoria-core/game/core-scripts/esc/commands/rand_global.gd
index a165f8d..9db0c20 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/rand_global.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/rand_global.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name RandGlobalCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/repeat.gd b/addons/escoria-core/game/core-scripts/esc/commands/repeat.gd
index 0c0e4b6..db891bf 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/repeat.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/repeat.gd
@@ -8,8 +8,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name RepeatCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
@@ -40,7 +40,7 @@ func configure() -> ESCCommandArgumentDescriptor:
## #### Returns[br]
## [br]
## Returns the execution result code. (`int`)
-func run(command_params: Array) -> int:
+func run(_command_params: Array) -> int:
return ESCExecution.RC_CANCEL
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/save_game.gd b/addons/escoria-core/game/core-scripts/esc/commands/save_game.gd
index e830c49..9f66993 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/save_game.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/save_game.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SaveGameCommand
+extends ESCBaseCommand
## Constructor[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/say.gd b/addons/escoria-core/game/core-scripts/esc/commands/say.gd
index 8c043c2..75115c5 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/say.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/say.gd
@@ -13,8 +13,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SayCommand
+extends ESCBaseCommand
## The keyword used to refer to the current player
const CURRENT_PLAYER_KEYWORD = "CURRENT_PLAYER"
@@ -103,14 +103,14 @@ func validate(arguments: Array):
func run(command_params: Array) -> int:
var dict: Dictionary
- escoria.current_state = escoria.GAME_STATE.DIALOG
+ escoria.current_state = escoria.GameState.DIALOG
if !escoria.dialog_player:
raise_error(
self,
"No dialog player was registered and the 'say' command was encountered."
)
- escoria.current_state = escoria.GAME_STATE.DEFAULT
+ escoria.current_state = escoria.GameState.DEFAULT
return ESCExecution.RC_ERROR
if not escoria.main.current_scene.player:
@@ -119,7 +119,7 @@ func run(command_params: Array) -> int:
"[%s]: No player item in the current scene was registered and the say command was encountered."
% get_command_name()
)
- escoria.current_state = escoria.GAME_STATE.DEFAULT
+ escoria.current_state = escoria.GameState.DEFAULT
return ESCExecution.RC_CANCEL
# Replace the names of any globals in "{ }" with their value
@@ -136,7 +136,7 @@ func run(command_params: Array) -> int:
command_params[2]
)
await escoria.dialog_player.say_finished
- escoria.current_state = escoria.GAME_STATE.DEFAULT
+ escoria.current_state = escoria.GameState.DEFAULT
return ESCExecution.RC_OK
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/sched_event.gd b/addons/escoria-core/game/core-scripts/esc/commands/sched_event.gd
index 5b57dca..3fb2029 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/sched_event.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/sched_event.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SchedEventCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
@@ -63,7 +63,7 @@ func validate(arguments: Array):
var esc_script = escoria.esc_compiler.load_esc_file(node.esc_script)
- if not arguments[2] in esc_script.events:
+ if not esc_script.has_event_with_target(arguments[2]):
raise_error(
self,
"Event with name '%s' not found." % arguments[2]
@@ -104,7 +104,7 @@ func run(command_params: Array) -> int:
var esc_script = escoria.esc_compiler.load_esc_file(node.esc_script)
escoria.event_manager.schedule_event(
- esc_script.events[command_params[2]], # ESCGrammarStmts.Event
+ esc_script.get_event_with_target(command_params[2]), # ESCGrammarStmts.Event
command_params[0], # timeout float
command_params[1] # object string
)
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/set_active.gd b/addons/escoria-core/game/core-scripts/esc/commands/set_active.gd
index 90846e5..97ebee5 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/set_active.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/set_active.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SetActiveCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/set_active_if_exists.gd b/addons/escoria-core/game/core-scripts/esc/commands/set_active_if_exists.gd
index ebb02cb..a2303fb 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/set_active_if_exists.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/set_active_if_exists.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SetActiveIfExistsCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/set_angle.gd b/addons/escoria-core/game/core-scripts/esc/commands/set_angle.gd
index ef503c4..936b03a 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/set_angle.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/set_angle.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SetAngleCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/set_animations.gd b/addons/escoria-core/game/core-scripts/esc/commands/set_animations.gd
index 7ae818f..9c22c02 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/set_animations.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/set_animations.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SetAnimationsCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
@@ -57,7 +57,7 @@ func validate(arguments: Array):
)
return false
- (escoria.object_manager.get_object(arguments[0]).node as ESCPlayer).validate_animations(load(arguments[1]))
+ (escoria.object_manager.get_object(arguments[0]).node as ESCItem).validate_animations(load(arguments[1]))
return true
@@ -74,7 +74,7 @@ func validate(arguments: Array):
## [br]
## Returns the execution result code. (`int`)
func run(command_params: Array) -> int:
- (escoria.object_manager.get_object(command_params[0]).node as ESCPlayer)\
+ (escoria.object_manager.get_object(command_params[0]).node as ESCItem)\
.animations = load(command_params[1])
if not escoria.globals_manager.has(
escoria.room_manager.GLOBAL_ANIMATION_RESOURCES
@@ -89,7 +89,7 @@ func run(command_params: Array) -> int:
)
if animations.is_empty():
animations = {}
- animations[command_params[0]] = command_params[1]
+ animations[command_params[0]] = command_params[1]
escoria.globals_manager.set_global(
escoria.room_manager.GLOBAL_ANIMATION_RESOURCES,
animations,
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/set_direction.gd b/addons/escoria-core/game/core-scripts/esc/commands/set_direction.gd
index 43bfd70..fde2cf0 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/set_direction.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/set_direction.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SetDirectionCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
@@ -55,14 +55,14 @@ func validate(arguments: Array):
% [get_command_name(), arguments[0]]
)
return false
- elif not escoria.object_manager.get_object(arguments[0]).node.is_movable:
+ if not escoria.object_manager.get_object(arguments[0]).node.is_movable:
escoria.logger.error(
self,
"[%s]: invalid object. Object with global id %s is not movable."
% [get_command_name(), arguments[0]]
)
return false
- elif arguments[1] < 0 or arguments[1] > escoria.object_manager.get_object(arguments[0]).node \
+ if arguments[1] < 0 or arguments[1] > escoria.object_manager.get_object(arguments[0]).node \
.get_directions_quantity() - 1:
escoria.logger.error(
self,
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/set_global.gd b/addons/escoria-core/game/core-scripts/esc/commands/set_global.gd
index 687fe16..f37751a 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/set_global.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/set_global.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SetGlobalCommand
+extends ESCBaseCommand
## The list of illegal strings that cannot be used in global names.
const ILLEGAL_STRINGS = ["/"]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/set_globals.gd b/addons/escoria-core/game/core-scripts/esc/commands/set_globals.gd
index 3107057..6122940 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/set_globals.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/set_globals.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SetGlobalsCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/set_gui_visible.gd b/addons/escoria-core/game/core-scripts/esc/commands/set_gui_visible.gd
index ec83445..d04f10c 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/set_gui_visible.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/set_gui_visible.gd
@@ -10,8 +10,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SetGuiVisibleCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/set_interactive.gd b/addons/escoria-core/game/core-scripts/esc/commands/set_interactive.gd
index 007e23a..45a8cfb 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/set_interactive.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/set_interactive.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SetInteractiveCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/set_item_custom_data.gd b/addons/escoria-core/game/core-scripts/esc/commands/set_item_custom_data.gd
index 2e90ce3..6985c36 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/set_item_custom_data.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/set_item_custom_data.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SetItemCustomDataCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/set_speed.gd b/addons/escoria-core/game/core-scripts/esc/commands/set_speed.gd
index cd8f2df..8b05814 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/set_speed.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/set_speed.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SetSpeedCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
@@ -49,6 +49,13 @@ func validate(arguments: Array):
if not escoria.object_manager.has(arguments[0]):
raise_invalid_object_error(self, arguments[0])
return false
+
+ if arguments[1] < 0:
+ raise_error(
+ self,
+ "Invalid speed. Speed cannot be negative (%d)." % arguments[1]
+ )
+ return false
return true
## Runs the command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/set_state.gd b/addons/escoria-core/game/core-scripts/esc/commands/set_state.gd
index 597751f..4efd6c5 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/set_state.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/set_state.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SetStateCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/show_menu.gd b/addons/escoria-core/game/core-scripts/esc/commands/show_menu.gd
index 3ee2935..32d971b 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/show_menu.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/show_menu.gd
@@ -10,8 +10,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name ShowMenuCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
@@ -73,7 +73,7 @@ func run(command_params: Array) -> int:
# Transition out from current scene
var transition_id = escoria.main.scene_transition.transition(
"",
- ESCTransitionPlayer.TRANSITION_MODE.OUT
+ ESCTransitionPlayer.TransitionMode.OUT
)
if transition_id != ESCTransitionPlayer.TRANSITION_ID_INSTANT:
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/slide.gd b/addons/escoria-core/game/core-scripts/esc/commands/slide.gd
index d168dfe..b3b56ab 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/slide.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/slide.gd
@@ -14,8 +14,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SlideCommand
+extends ESCBaseCommand
## A hash of tweens currently active for animated items
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/slide_block.gd b/addons/escoria-core/game/core-scripts/esc/commands/slide_block.gd
index 59fa186..397b5d3 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/slide_block.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/slide_block.gd
@@ -13,8 +13,8 @@
##
## @ASHES
## @COMMAND
-extends SlideCommand
class_name SlideBlockCommand
+extends SlideCommand
## Runs the command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/spawn.gd b/addons/escoria-core/game/core-scripts/esc/commands/spawn.gd
index b0f01ba..b699816 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/spawn.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/spawn.gd
@@ -13,8 +13,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name SpawnCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/stop.gd b/addons/escoria-core/game/core-scripts/esc/commands/stop.gd
index b53183f..578bb3e 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/stop.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/stop.gd
@@ -8,8 +8,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name StopCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
@@ -40,7 +40,7 @@ func configure() -> ESCCommandArgumentDescriptor:
## #### Returns[br]
## [br]
## Returns the execution result code. (`int`)
-func run(command_params: Array) -> int:
+func run(_command_params: Array) -> int:
return ESCExecution.RC_CANCEL
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/stop_snd.gd b/addons/escoria-core/game/core-scripts/esc/commands/stop_snd.gd
index ae0fc62..5218d3a 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/stop_snd.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/stop_snd.gd
@@ -10,15 +10,27 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name StopSndCommand
+extends ESCBaseCommand
+## The previous sound state, saved for interrupting
+var previous_snd_state: String
## The specified sound player
var _snd_player: String
-## The previous sound state, saved for interrupting
-var previous_snd_state: String
+
+## Constructor of this command.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _init() -> void:
+ run_in_ready_allowed_during_savegame_loading = false
## The descriptor of the arguments of this command.[br]
@@ -93,20 +105,20 @@ func run(command_params: Array) -> int:
## [br]
## Returns nothing.
func interrupt():
- var _sound_players = []
+ var sound_players = []
if previous_snd_state.is_empty():
previous_snd_state = "off"
if _snd_player.is_empty():
- _sound_players = [
+ sound_players = [
ESCObjectManager.MUSIC,
ESCObjectManager.SOUND,
ESCObjectManager.SPEECH
]
else:
- _sound_players = [_snd_player]
+ sound_players = [_snd_player]
- for snd_player in _sound_players:
+ for snd_player in sound_players:
if escoria.object_manager.get_object(snd_player).node:
escoria.object_manager.get_object(snd_player).node.set_state(
previous_snd_state
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/teleport.gd b/addons/escoria-core/game/core-scripts/esc/commands/teleport.gd
index ae41393..f0d7895 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/teleport.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/teleport.gd
@@ -11,8 +11,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name TeleportCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/teleport_pos.gd b/addons/escoria-core/game/core-scripts/esc/commands/teleport_pos.gd
index bf7a4fe..c561dfc 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/teleport_pos.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/teleport_pos.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name TeleportPosCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/transition.gd b/addons/escoria-core/game/core-scripts/esc/commands/transition.gd
index ff2c562..192fcdc 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/transition.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/transition.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name TransitionCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
@@ -78,8 +78,8 @@ func validate(arguments: Array):
func run(command_params: Array) -> int:
var transition_id = escoria.main.scene_transition.transition(
command_params[0],
- ESCTransitionPlayer.TRANSITION_MODE.OUT if command_params[1] == "out" \
- else ESCTransitionPlayer.TRANSITION_MODE.IN,
+ ESCTransitionPlayer.TransitionMode.OUT if command_params[1] == "out" \
+ else ESCTransitionPlayer.TransitionMode.IN,
command_params[2]
)
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/turn_to.gd b/addons/escoria-core/game/core-scripts/esc/commands/turn_to.gd
index 6cf6fc4..efb5e7a 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/turn_to.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/turn_to.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name TurnToCommand
+extends ESCBaseCommand
## The descriptor of the arguments of this command.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/wait.gd b/addons/escoria-core/game/core-scripts/esc/commands/wait.gd
index 1775452..1032a55 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/wait.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/wait.gd
@@ -10,8 +10,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name WaitCommand
+extends ESCBaseCommand
## Timer to wait for
var timer: Timer
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/walk.gd b/addons/escoria-core/game/core-scripts/esc/commands/walk.gd
index 735e199..b38d816 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/walk.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/walk.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name WalkCommand
+extends ESCBaseCommand
## Walking object
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/walk_block.gd b/addons/escoria-core/game/core-scripts/esc/commands/walk_block.gd
index d144a6d..7218b87 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/walk_block.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/walk_block.gd
@@ -12,8 +12,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name WalkBlockCommand
+extends ESCBaseCommand
## Walking object
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/walk_to_pos.gd b/addons/escoria-core/game/core-scripts/esc/commands/walk_to_pos.gd
index fcbe673..f8303dc 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/walk_to_pos.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/walk_to_pos.gd
@@ -13,8 +13,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name WalkToPosCommand
+extends ESCBaseCommand
## Walking object
@@ -56,7 +56,7 @@ func validate(arguments: Array):
if not escoria.object_manager.has(arguments[0]):
raise_error(
self,
- "Invalid first object. The object to make walk with global id '%s' was not found." % arguments[0]
+ "Invalid first object. The object to make walk with global id '%s' was not found." % arguments[0]
)
return false
diff --git a/addons/escoria-core/game/core-scripts/esc/commands/walk_to_pos_block.gd b/addons/escoria-core/game/core-scripts/esc/commands/walk_to_pos_block.gd
index 6800522..7ff1a15 100644
--- a/addons/escoria-core/game/core-scripts/esc/commands/walk_to_pos_block.gd
+++ b/addons/escoria-core/game/core-scripts/esc/commands/walk_to_pos_block.gd
@@ -13,8 +13,8 @@
## [br]
## @ASHES
## @COMMAND
-extends ESCBaseCommand
class_name WalkToPosBlockCommand
+extends ESCBaseCommand
## Walking object
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_break_counter.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_break_counter.gd
index df14c0b..53d8927 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_break_counter.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_break_counter.gd
@@ -1,20 +1,21 @@
-## Used by the intpreter to track the depth of a `break` statement invoked in a dialog.
-extends Object
+## Used by the intpreter to track dialog `break` propagation state.
class_name ESCBreakCounter
+extends Object
var _levels_left: int = 0:
set = set_levels_left,
get = get_levels_left
+var _resume_parent_dialog: bool = false
-## Sets the depth from the root of the dialog tree, with `levels == 0` at the root.[br]
+## Sets the remaining number of dialog levels to unwind before control should return to a parent dialog, where `levels == 0` means the unwind has reached the parent dialog that should resume.[br]
## [br]
## #### Parameters[br]
## [br]
## | Name | Type | Description | Required? |[br]
## |:-----|:-----|:------------|:----------|[br]
-## |levels|`int`|Number of dialog nesting levels remaining (0 represents the root).|yes|[br]
+## |levels|`int`|Number of dialog nesting levels still to exit before resuming a parent dialog.|yes|[br]
## [br]
## #### Returns[br]
## [br]
@@ -23,7 +24,7 @@ func set_levels_left(levels: int) -> void:
_levels_left = levels
-## Gets the depth from the root of the dialog tree, with `levels == 0` at the root.[br]
+## Gets the remaining number of dialog levels to unwind before control should return to a parent dialog, where `levels == 0` means the unwind has reached the parent dialog that should resume.[br]
## [br]
## #### Parameters[br]
## [br]
@@ -31,12 +32,68 @@ func set_levels_left(levels: int) -> void:
## [br]
## #### Returns[br]
## [br]
-## Returns a `int` value. (`int`)
+## Returns an `int` value.
func get_levels_left() -> int:
return _levels_left
-## Decrements by one the depth from the root of the dialog tree, with `levels == 0` at the root.[br]
+## Returns whether more dialog levels still need to be exited before reaching the parent dialog that should resume.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns `true` if additional dialog levels must still be unwound, otherwise `false`. (`bool`)
+func has_levels_left() -> bool:
+ return _levels_left > 0
+
+
+## Advances the dialog-break state upward by one dialog level.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func advance_up_one_level() -> void:
+ if _levels_left > 0:
+ _levels_left -= 1
+
+ if _levels_left == 0:
+ _resume_parent_dialog = true
+
+
+## Marks the dialog-break state so the receiving dialog frame resumes its own option loop instead of concluding.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func mark_resume_parent_dialog() -> void:
+ _resume_parent_dialog = true
+
+
+## Returns whether the receiving dialog frame should resume its option loop.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns `true` if the parent dialog should resume, otherwise `false`. (`bool`)
+func should_resume_parent_dialog() -> bool:
+ return _resume_parent_dialog
+
+
+## Clears the resume-parent-dialog state after it has been consumed by a dialog frame.[br]
## [br]
## #### Parameters[br]
## [br]
@@ -45,5 +102,5 @@ func get_levels_left() -> int:
## #### Returns[br]
## [br]
## Returns nothing.
-func dec_levels_left() -> void:
- _levels_left = _levels_left - 1
+func consume_parent_resume() -> void:
+ _resume_parent_dialog = false
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_environment.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_environment.gd
index d63c52b..f89bed9 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_environment.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_environment.gd
@@ -2,8 +2,8 @@
##
## Scoping is implemented using this class, including traditional scoping rules.
## Scope binding and resolution are handled in this class.
-extends Object
class_name ESCEnvironment
+extends Object
var _enclosing: # ESCEnvironment
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_expr.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_expr.gd
index 0e8bda8..ee94810 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_expr.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_expr.gd
@@ -3,8 +3,8 @@
## All inheriting classes must implement an `accept` method to invoke the visitor.
## For more information on the "Visitor" pattern, see:
## (Visitor Pattern)[https://en.wikipedia.org/wiki/Visitor_pattern]
-extends RefCounted
class_name ESCGrammarExpr
+extends RefCounted
## Method that invokes another method in the visitor against this, the implementing class.[br]
@@ -18,5 +18,5 @@ class_name ESCGrammarExpr
## #### Returns[br]
## [br]
## Returns nothing.
-func accept(visitor):
+func accept(_visitor):
pass
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_stmt.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_stmt.gd
index 1793f0e..126e8cc 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_stmt.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_stmt.gd
@@ -3,8 +3,8 @@
## All inheriting classes must implement an `accept` method to invoke the visitor.
## For more information on the "Visitor" pattern, see:
## (Visitor Pattern)[https://en.wikipedia.org/wiki/Visitor_pattern]
-extends RefCounted
class_name ESCGrammarStmt
+extends RefCounted
## Method that invokes another method in the visitor against this, the implementing class.[br]
@@ -18,5 +18,5 @@ class_name ESCGrammarStmt
## #### Returns[br]
## [br]
## Returns nothing.
-func accept(visitor):
+func accept(_visitor):
pass
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_stmts.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_stmts.gd
index 2e9c575..07c197c 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_stmts.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_stmts.gd
@@ -154,6 +154,27 @@ class Event extends ESCGrammarStmt:
## [br]
signal interrupted
+ ## Valid event flags[br]
+ ##[br]
+ ## * TK: stands for "telekinetic". It means the player won't walk over to
+ ## the item to say the line.[br]
+ ## * NO_TT: stands for "No tooltip". It hides the tooltip for the duration of
+ ## the event. Probably not very useful, because events having multiple
+ ## say commands in them are automatically hidden.[br]
+ ## * NO_UI: stands for "No User Inteface". It hides the UI for the duration of
+ ##  the event. Useful when you want something to look like a cut scene but not
+ ## disable input for skipping dialog.[br]
+ ## * NO_SAVE: disables saving. Use this in cut scenes and anywhere a
+ ## badly-timed autosave would leave your game in a messed-up state.
+ enum {
+ FLAG_TK = 1,
+ FLAG_NO_TT = 2,
+ FLAG_NO_UI = 4,
+ FLAG_NO_SAVE = 8
+ }
+
+ var source: String = "" # TODO: Make proper use of this
+
var _name: ESCToken:
get = get_name
var _target: ESCGrammarExprs.Literal:
@@ -175,28 +196,6 @@ class Event extends ESCGrammarStmt:
var _is_interrupted: bool = false:
get = is_interrupted
- var source: String = "" # TODO: Make proper use of this
-
-
- ## Valid event flags[br]
- ##[br]
- ## * TK: stands for "telekinetic". It means the player won't walk over to
- ## the item to say the line.[br]
- ## * NO_TT: stands for "No tooltip". It hides the tooltip for the duration of
- ## the event. Probably not very useful, because events having multiple
- ## say commands in them are automatically hidden.[br]
- ## * NO_UI: stands for "No User Inteface". It hides the UI for the duration of
- ##  the event. Useful when you want something to look like a cut scene but not
- ## disable input for skipping dialog.[br]
- ## * NO_SAVE: disables saving. Use this in cut scenes and anywhere a
- ## badly-timed autosave would leave your game in a messed-up state.
- enum {
- FLAG_TK = 1,
- FLAG_NO_TT = 2,
- FLAG_NO_UI = 4,
- FLAG_NO_SAVE = 8
- }
-
## Initialization method. Must be called after instantiation.[br]
## [br]
@@ -784,6 +783,8 @@ class Stop extends ESCGrammarStmt:
## say("worker", "Because I work here.")[br]
##```
class DialogOption extends ESCGrammarStmt:
+ var _translation_key: String:
+ get = get_translation_key
var _option: ESCGrammarExpr:
get = get_option
var _condition: ESCGrammarExpr:
@@ -798,6 +799,7 @@ class DialogOption extends ESCGrammarStmt:
## [br]
## | Name | Type | Description | Required? |[br]
## |:-----|:-----|:------------|:----------|[br]
+ ## |translation_key|`String`|an optional translation key for the option text|yes|[br]
## |option|`ESCGrammarExpr`|the expression whose results will be displayed on screen as an option to be selected alongside any other options at the current level|yes|[br]
## |condition|`ESCGrammarExpr`|an optional condition to be evaluated that is used to determine whether the option should be displayed at all|yes|[br]
## |body|`ESCGrammarStmt`|the block of statements to be executed should this dialog option be selected|yes|[br]
@@ -805,12 +807,26 @@ class DialogOption extends ESCGrammarStmt:
## #### Returns[br]
## [br]
## Returns nothing.
- func init(option: ESCGrammarExpr, condition: ESCGrammarExpr, body: ESCGrammarStmt):
+ func init(translation_key: String, option: ESCGrammarExpr, condition: ESCGrammarExpr, body: ESCGrammarStmt):
+ _translation_key = translation_key
_option = option
_condition = condition
_body = body
+ ## The translation key for this option, if one exists.[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## None.
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns the translation key for this option, if one exists. (`String`)
+ func get_translation_key() -> String:
+ return _translation_key
+
+
## The option expression.[br]
## [br]
## #### Parameters[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter.gd
index 0944486..2097890 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter.gd
@@ -1,6 +1,6 @@
## The actual interpreter that processes a parsed ASHES script.
-extends RefCounted
class_name ESCInterpreter
+extends RefCounted
## Used to represent the current player character in scripts.
@@ -26,6 +26,7 @@ var _locals: Dictionary = {}
# Still, this should be tested extensively, and, if at all possible, with multiple
# concurrent events running.
var _current_event: ESCGrammarStmts.Event
+var _dialog_depth: int = 0
# While most of the time we only run a single event at a time, it is possible for
# multiple events to
@@ -34,10 +35,12 @@ var _event_stack: Array = []
var _builtin_functions: Array = [
"print"
]
+var _channel_name: String = ""
-func _init(callables: Array, globals: Dictionary):
+func _init(callables: Array, globals: Dictionary, channel_name: String = ""):
_globals = ESCEnvironment.new()
+ _channel_name = channel_name
for callable in callables:
_globals.define(callable.get_command_name(), callable)
@@ -77,6 +80,19 @@ func get_global_values() -> Dictionary:
return _globals.get_values()
+## Gets the event-manager channel this interpreter is executing on.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns the channel name associated with this interpreter run. (`String`)
+func get_channel_name() -> String:
+ return _channel_name
+
+
## Resets the interpreter, specifically any locally-scoped variables.[br]
## [br]
## #### Parameters[br]
@@ -253,13 +269,15 @@ func visit_call_expr(expr: ESCGrammarExprs.Call):
self,
"Can only call valid commands."
)
- else:
- return _handle_builtin_function(callee, args)
+ return ESCExecution.RC_ERROR
+
+ return _handle_builtin_function(callee, args)
var command = ESCCommand.new()
command.parameters = args
command.name = callee.get_command_name()
command.parser_token = expr.get_paren_token()
+ command.channel_name = _channel_name
var rc = ESCExecution.RC_OK
@@ -282,11 +300,12 @@ func _handle_builtin_function(fn_name: String, args: Array) -> int:
match fn_name:
'print':
- if args.size() > 1:
+ if args.size() != 1:
escoria.logger.error(
self,
- "'print' only takes one argument"
+ "'print' expects exactly one argument"
)
+ return rc
_print(args)
rc = ESCExecution.RC_OK
@@ -315,13 +334,13 @@ func _print(value):
func visit_if_stmt(stmt: ESCGrammarStmts.If):
if _is_truthy(await _evaluate(stmt.get_condition())):
return await _execute(stmt.get_then_branch())
- else:
- for branch in stmt.get_elif_branches():
- if _is_truthy(await _evaluate(branch.get_condition())):
- return await _execute(branch)
- if stmt.get_else_branch():
- return await _execute(stmt.get_else_branch())
+ for branch in stmt.get_elif_branches():
+ if _is_truthy(await _evaluate(branch.get_condition())):
+ return await _execute(branch)
+
+ if stmt.get_else_branch():
+ return await _execute(stmt.get_else_branch())
return null
@@ -344,6 +363,9 @@ func visit_while_stmt(stmt: ESCGrammarStmts.While):
if ret is ESCGrammarStmts.Break:
break
+ if _is_terminal_control_flow(ret):
+ return ret
+
return null
@@ -358,10 +380,23 @@ func visit_while_stmt(stmt: ESCGrammarStmts.While):
## #### Returns[br]
## [br]
## Returns nothing.
-func visit_pass_stmt(stmt: ESCGrammarStmts.Pass):
+func visit_pass_stmt(_stmt: ESCGrammarStmts.Pass):
pass
+func _is_terminal_control_flow(ret) -> bool:
+ if ret is ESCGrammarStmts.Stop \
+ or ret is ESCGrammarStmts.Done \
+ or ret is ESCBreakCounter:
+ return true
+
+ if typeof(ret) == TYPE_INT:
+ return ret == ESCExecution.RC_ERROR \
+ or ret == ESCExecution.RC_INTERRUPTED
+
+ return false
+
+
## Executes code relevant to interpreting a `stop` statement. Relevant only to dialogs.[br]
## [br]
## #### Parameters[br]
@@ -438,14 +473,76 @@ func visit_global_stmt(stmt: ESCGrammarStmts.Global):
func visit_dialog_stmt(stmt: ESCGrammarStmts.Dialog):
var dialog: ESCDialog = ESCDialog.new()
var rc = ESCExecution.RC_OK
+ var dialog_args := stmt.get_args()
+ var dialog_options := stmt.get_options()
+
+ _dialog_depth += 1
+ dialog.authored_options_count = dialog_options.size()
+
+ if dialog_args.size() > 0:
+ var avatar = await _evaluate(dialog_args[0])
+
+ if avatar != null and not avatar is String and not avatar is StringName:
+ escoria.logger.error(
+ self,
+ "Dialog avatar must evaluate to a String."
+ )
+ _dialog_depth -= 1
+ return ESCExecution.RC_ERROR
+
+ dialog.avatar = str(avatar)
+
+ if dialog_args.size() > 1:
+ var timeout = await _evaluate(dialog_args[1])
+
+ if not timeout is int and not timeout is float:
+ escoria.logger.error(
+ self,
+ "Dialog timeout must evaluate to a number."
+ )
+ _dialog_depth -= 1
+ return ESCExecution.RC_ERROR
+
+ if timeout < 0:
+ escoria.logger.error(
+ self,
+ "Dialog timeout must be greater than or equal to 0."
+ )
+ _dialog_depth -= 1
+ return ESCExecution.RC_ERROR
+
+ dialog.timeout = int(timeout)
+
+ if dialog_args.size() > 2:
+ var timeout_option = await _evaluate(dialog_args[2])
+
+ if not timeout_option is int and not timeout_option is float:
+ escoria.logger.error(
+ self,
+ "Dialog timeout option must evaluate to a number."
+ )
+ _dialog_depth -= 1
+ return ESCExecution.RC_ERROR
+
+ if timeout_option < 0:
+ escoria.logger.error(
+ self,
+ "Dialog timeout option must be greater than or equal to 0."
+ )
+ _dialog_depth -= 1
+ return ESCExecution.RC_ERROR
+
+ dialog.timeout_option = int(timeout_option)
while true:
dialog.options = []
- for dialog_option in stmt.get_options():
+ for index in range(dialog_options.size()):
+ var dialog_option = dialog_options[index]
var option: ESCDialogOption = ESCDialogOption.new()
- # TODO: Translation keys
option.source_option = dialog_option
+ option.source_option_index = index + 1
+ option.translation_key = dialog_option.get_translation_key()
option.option = await _evaluate(dialog_option.get_option())
if dialog_option.get_condition():
@@ -453,12 +550,23 @@ func visit_dialog_stmt(stmt: ESCGrammarStmts.Dialog):
else:
option.set_is_valid(true)
- dialog.options.append(option)
+ # Dialog execution should stop once no valid options remain. Keep only
+ # currently valid options in the presented list so an exhausted dialog
+ # concludes instead of re-running forever with unselectable entries.
+ if option.is_valid():
+ dialog.options.append(option)
if dialog.options.size() == 0:
break
- if dialog.is_valid() and not _current_event.is_interrupted():
+ # If the event was interrupted while a nested command or dialog body was
+ # active, stop the dialog frame immediately instead of looping and
+ # presenting options again.
+ if _current_event and _current_event.is_interrupted():
+ _dialog_depth -= 1
+ return ESCExecution.RC_INTERRUPTED
+
+ if dialog.is_valid():
var chosen_option = await dialog.run()
if chosen_option:
@@ -468,22 +576,73 @@ func visit_dialog_stmt(stmt: ESCGrammarStmts.Dialog):
"Chosen dialog option (%s) was completed." % chosen_option
)
- if execute_ret is ESCGrammarStmts.Break:
- var break_tracker: ESCBreakCounter = ESCBreakCounter.new()
+ # Dialog frames must propagate terminal runtime errors the same
+ # way normal blocks do. Otherwise an invalid command or similar
+ # failure inside an option body is treated like normal completion
+ # and the dialog loop re-displays the same options indefinitely.
+ if typeof(execute_ret) == TYPE_INT and execute_ret == ESCExecution.RC_ERROR:
+ _dialog_depth -= 1
+ return ESCExecution.RC_ERROR
+ # An interruption can happen while the chosen option body is still
+ # running, so check again after it returns before processing normal
+ # dialog control flow like `break`, `done`, or `stop`.
+ if _current_event and _current_event.is_interrupted():
+ _dialog_depth -= 1
+ return ESCExecution.RC_INTERRUPTED
+
+ if execute_ret is ESCGrammarStmts.Break:
+ var levels_left := 0
if execute_ret.get_levels():
- break_tracker.set_levels_left(await _evaluate(execute_ret.get_levels()) - 1)
+ levels_left = await _evaluate(execute_ret.get_levels()) - 1
+
+ # A top-level dialog consumes `break` by concluding the current dialog.
+ if _dialog_depth == 1:
+ break
+
+ # Nested dialogs propagate explicit break state upward so parent
+ # dialog frames can decide whether to keep unwinding or resume.
+ var break_tracker: ESCBreakCounter = ESCBreakCounter.new()
+ if levels_left > 0:
+ break_tracker.set_levels_left(levels_left)
else:
- break_tracker.set_levels_left(0)
+ break_tracker.mark_resume_parent_dialog()
+ _dialog_depth -= 1
return break_tracker
- elif execute_ret is ESCGrammarStmts.Done:
+ if execute_ret is ESCGrammarStmts.Done:
+ _dialog_depth -= 1
+
+ if _dialog_depth == 0:
+ return rc
+
return execute_ret
- elif execute_ret is ESCBreakCounter:
- if execute_ret.get_levels_left() > 0:
- execute_ret.dec_levels_left()
+
+ if execute_ret is ESCGrammarStmts.Stop:
+ # `stop` aborts the entire event, so dialog frames must pass it through.
+ _dialog_depth -= 1
+ return execute_ret
+
+ if execute_ret is ESCBreakCounter:
+ if execute_ret.has_levels_left():
+ # Once the unwind reaches the top-most dialog, the remaining
+ # levels are consumed by concluding that dialog entirely.
+ if _dialog_depth == 1:
+ break
+
+ execute_ret.advance_up_one_level()
+ _dialog_depth -= 1
return execute_ret
+ if execute_ret.should_resume_parent_dialog():
+ # The requested dialog levels have been exited, so this frame
+ # resumes presenting its own options instead of concluding.
+ execute_ret.consume_parent_resume()
+ continue
+
+ break
+
+ _dialog_depth -= 1
return rc
@@ -498,7 +657,7 @@ func visit_dialog_stmt(stmt: ESCGrammarStmts.Dialog):
## #### Returns[br]
## [br]
## Returns nothing.
-func visit_dialog_option_stmt(stmt: ESCGrammarStmts.DialogOption):
+func visit_dialog_option_stmt(_stmt: ESCGrammarStmts.DialogOption):
pass
@@ -623,18 +782,28 @@ func visit_binary_expr(expr: ESCGrammarExprs.Binary):
return not _is_equal(left_part, right_part)
ESCTokenType.TokenType.GREATER:
var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ if not check:
+ return null
return left_part > right_part
ESCTokenType.TokenType.GREATER_EQUAL:
var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ if not check:
+ return null
return left_part >= right_part
ESCTokenType.TokenType.LESS:
var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ if not check:
+ return null
return left_part < right_part
ESCTokenType.TokenType.LESS_EQUAL:
var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ if not check:
+ return null
return left_part <= right_part
ESCTokenType.TokenType.MINUS:
var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ if not check:
+ return null
return left_part - right_part
ESCTokenType.TokenType.PLUS:
var check = _check_are_numbers(left_part, expr.get_operator(), right_part, false)
@@ -653,9 +822,13 @@ func visit_binary_expr(expr: ESCGrammarExprs.Binary):
)
ESCTokenType.TokenType.SLASH:
var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ if not check:
+ return null
return left_part / right_part
ESCTokenType.TokenType.STAR:
var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ if not check:
+ return null
return left_part * right_part
return null
@@ -680,6 +853,8 @@ func visit_unary_expr(expr: ESCGrammarExprs.Unary):
return not _is_truthy(right_part)
ESCTokenType.TokenType.MINUS:
var check = _check_is_number(right_part, expr.get_operator())
+ if not check:
+ return null
return -right_part
return null
@@ -793,8 +968,8 @@ func look_up_variable(name: ESCToken, expr: ESCGrammarExpr):
if distance == -1:
return _globals.get_value(name)
- else:
- return _environment.get_at(distance, name.get_lexeme())
+
+ return _environment.get_at(distance, name.get_lexeme())
# Private methods
@@ -860,10 +1035,18 @@ func _execute_block(statements: Array, env: ESCEnvironment):
for stmt in statements:
ret = await _execute(stmt)
- if ret is ESCGrammarStmts.Break \
- or ret is ESCGrammarStmts.Done \
- or ret is ESCGrammarStmts.Stop:
+ # If the running event was interrupted while this statement was active,
+ # stop the block immediately so later statements do not continue running.
+ if _current_event and _current_event.is_interrupted():
+ _environment = previous_env
+ return ESCExecution.RC_INTERRUPTED
+ if ret is ESCGrammarStmts.Break:
+ _environment = previous_env
+ return ret
+
+ if _is_terminal_control_flow(ret):
+ _environment = previous_env
return ret
# TODO: Proper error handling per statement?
@@ -925,12 +1108,12 @@ func _check_at_least_one_string(value_1, value_2):
return typeof(value_1) == TYPE_STRING || typeof(value_2) == TYPE_STRING
-func _on_global_changed(key: String, old_value, new_value) -> void:
+func _on_global_changed(key: String, _old_value, new_value) -> void:
# Shoehorn this in as an adapter
var token: ESCToken = ESCToken.new()
token.init(ESCTokenType.TokenType.IDENTIFIER, key, null, "", -1, "")
if _globals.get_values().has(key):
_globals.assign(token, new_value)
- elif escoria.save_manager.is_loading_game:
+ else:
_globals.define(key, new_value)
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter_factory.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter_factory.gd
index 4205f92..24f6c66 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter_factory.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter_factory.gd
@@ -1,6 +1,10 @@
## Factory class for producing an ASHES interpreter.
-extends RefCounted
class_name ESCInterpreterFactory
+extends RefCounted
+
+const ESC_INTERPRETER_SCRIPT := preload(
+ "res://addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter.gd"
+)
static var _interpreter: ESCInterpreter = null
@@ -15,16 +19,64 @@ static var _interpreter: ESCInterpreter = null
## #### Returns[br]
## [br]
## Returns a `ESCInterpreter` value. (`ESCInterpreter`)
-static func create_interpreter() -> ESCInterpreter:
- if not _interpreter:
- _interpreter = load("res://addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter.gd").new(ESCCompiler.load_commands(), ESCCompiler.load_globals())
- return _interpreter
+static func create_interpreter(channel_name: String = "") -> ESCInterpreter:
+ return create_utility_interpreter(channel_name)
+
- _interpreter = load("res://addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter.gd").new([], _interpreter.get_global_values())
+## Produces an interpreter for non-runtime utility work such as resolving or
+## evaluating statements outside the event manager.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |channel_name|`String`|Optional channel name to assign to the created interpreter.|no|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a cached-or-reseeded `ESCInterpreter` instance for utility usage. (`ESCInterpreter`)
+static func create_utility_interpreter(channel_name: String = "") -> ESCInterpreter:
+ if not _interpreter:
+ _interpreter = ESC_INTERPRETER_SCRIPT.new(
+ ESCCompiler.load_commands(),
+ ESCCompiler.load_globals(),
+ channel_name
+ )
+ else:
+ _interpreter = ESC_INTERPRETER_SCRIPT.new(
+ [],
+ _interpreter.get_global_values(),
+ channel_name
+ )
return _interpreter
+## Produces a fresh interpreter intended for event-manager runtime execution.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |channel_name|`String`|Optional channel name to assign to the created interpreter.|no|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a newly-created `ESCInterpreter` instance for runtime usage. (`ESCInterpreter`)
+static func create_runtime_interpreter(channel_name: String = "") -> ESCInterpreter:
+ var runtime_globals := ESCCompiler.load_globals()
+
+ if not Engine.is_editor_hint() and escoria and escoria.globals_manager:
+ runtime_globals.merge(escoria.globals_manager.get_globals(), true)
+
+ var interpreter := ESC_INTERPRETER_SCRIPT.new(
+ ESCCompiler.load_commands(),
+ runtime_globals,
+ channel_name
+ )
+ return interpreter
+
+
static func reset_interpreter() -> void:
if is_instance_valid(_interpreter):
_interpreter.cleanup()
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_parse_error.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_parse_error.gd
index 5fd01a3..1bb5b19 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_parse_error.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_parse_error.gd
@@ -1,3 +1,3 @@
## Class to represent an error that occured during parsing.
-extends Object
class_name ESCParseError
+extends Object
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_parser.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_parser.gd
index b88adcb..6229e34 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_parser.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_parser.gd
@@ -4,8 +4,8 @@
## Note that the vast majority of this class consists of (effectively) private methods
## in order to facilitate encapsulation, but if you're interested in how the parser
## is implemented, [check out the class in GitHub](https://github.com/godot-escoria/escoria-demo-game/blob/main/addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter.gd)
-extends RefCounted
class_name ESCParser
+extends RefCounted
var _tokens: Array
@@ -52,48 +52,56 @@ func parse() -> Array:
var statements: Array = []
while not _at_end():
- statements.append(_declaration())
+ var declaration = _declaration()
+ if declaration != null:
+ statements.append(declaration)
return statements
func _declaration() -> ESCGrammarStmt:
- var retStmt
+ while _match(ESCTokenType.TokenType.NEWLINE) or _match(ESCTokenType.TokenType.DEDENT):
+ pass
+
+ if _at_end():
+ return null
+
+ var ret_stmt
if _match(ESCTokenType.TokenType.COLON):
- retStmt = _event_declaration()
+ ret_stmt = _event_declaration()
- if retStmt is ESCParseError:
+ if ret_stmt is ESCParseError:
_synchronize()
return null
- else:
- return retStmt
+
+ return ret_stmt
if _match(ESCTokenType.TokenType.VAR):
- retStmt = _var_declaration()
+ ret_stmt = _var_declaration()
- if retStmt is ESCParseError:
+ if ret_stmt is ESCParseError:
_synchronize()
return null
- else:
- return retStmt
+
+ return ret_stmt
if _match(ESCTokenType.TokenType.GLOBAL):
- retStmt = _global_declaration()
+ ret_stmt = _global_declaration()
- if retStmt is ESCParseError:
+ if ret_stmt is ESCParseError:
_synchronize()
return null
- else:
- return retStmt
- retStmt = _statement()
+ return ret_stmt
+
+ ret_stmt = _statement()
- if retStmt is ESCParseError:
+ if ret_stmt is ESCParseError:
_synchronize()
return null
- return retStmt
+ return ret_stmt
func _event_declaration():
@@ -157,8 +165,8 @@ func _event_declaration():
ret.init(name, target, flags, body, _associated_object_global_id)
return ret
- else:
- return _error(_peek(), "Expected block after event declaration for '%s'. Code blocks require tab(s) at the start of a line." % name.get_lexeme())
+
+ return _error(_peek(), "Expected block after event declaration for '%s'. Code blocks require tab(s) at the start of a line." % name.get_lexeme())
func _literal_from_identifier(expr: ESCGrammarExprs.Variable) -> ESCGrammarExprs.Literal:
@@ -314,21 +322,25 @@ func _while_statement():
var condition = _expression()
if condition is ESCParseError:
+ _loop_level -= 1
return condition
var colon_token = _consume(ESCTokenType.TokenType.COLON, "Expect ':' after condition.")
if colon_token is ESCParseError:
+ _loop_level -= 1
return colon_token
var consume = _consume_new_block_start("start of while loop")
if consume:
+ _loop_level -= 1
return consume
var block = _block()
if block is ESCParseError:
+ _loop_level -= 1
return block
var body = ESCGrammarStmts.Block.new()
@@ -372,6 +384,7 @@ func _dialog_statement():
var arg = _expression()
if arg is ESCParseError:
+ _dialog_level -= 1
return arg
args.append(arg)
@@ -382,14 +395,32 @@ func _dialog_statement():
var consume = _consume(ESCTokenType.TokenType.RIGHT_PAREN, "Expect ')' after start dialog arguments.")
if consume is ESCParseError:
+ _dialog_level -= 1
return consume
+ elif not _check(ESCTokenType.TokenType.NEWLINE):
+ while true:
+ var arg = _expression()
+
+ if arg is ESCParseError:
+ _dialog_level -= 1
+ return arg
+
+ args.append(arg)
+
+ if _check(ESCTokenType.TokenType.NEWLINE):
+ break
+
+ if _match(ESCTokenType.TokenType.COMMA):
+ continue
if args.size() > 3:
+ _dialog_level -= 1
return _error(_peek(), "Start dialog cannot have more than 3 arguments.")
var consume = _consume_new_block_start("dialog start")
if consume is ESCParseError:
+ _dialog_level -= 1
return consume
var options: Array = []
@@ -398,6 +429,7 @@ func _dialog_statement():
var dialog_option = _dialog_option_statement()
if dialog_option is ESCParseError:
+ _dialog_level -= 1
return dialog_option
options.append(dialog_option)
@@ -433,6 +465,14 @@ func _dialog_option_statement():
if consume is ESCParseError:
return consume
+ var translation_key := ""
+
+ if _match_in_order([
+ ESCTokenType.TokenType.IDENTIFIER,
+ ESCTokenType.TokenType.COLON
+ ]):
+ translation_key = _tokens[_current - 2].get_lexeme()
+
var expr = _expression()
if expr is ESCParseError:
@@ -448,8 +488,14 @@ func _dialog_option_statement():
consume = _consume(ESCTokenType.TokenType.RIGHT_SQUARE, "Expect ']' after dialog option condition")
+ if consume is ESCParseError:
+ return consume
+
consume = _consume_new_block_start("dialog option")
+ if consume is ESCParseError:
+ return consume
+
var block_stmts = _block()
if block_stmts is ESCParseError:
@@ -459,7 +505,7 @@ func _dialog_option_statement():
block.init(block_stmts)
var option: ESCGrammarStmts.DialogOption = ESCGrammarStmts.DialogOption.new()
- option.init(expr, condition, block)
+ option.init(translation_key, expr, condition, block)
return option
@@ -512,7 +558,8 @@ func _block():
if decl is ESCParseError:
return decl
- statements.append(decl)
+ if decl != null:
+ statements.append(decl)
_consume(ESCTokenType.TokenType.DEDENT, "Expected DEDENT after block.")
return statements
@@ -536,7 +583,8 @@ func _assignment():
var ret = ESCGrammarExprs.Assign.new()
ret.init(name, value)
return ret
- elif expr is ESCGrammarExprs.Get:
+
+ if expr is ESCGrammarExprs.Get:
var ret = ESCGrammarExprs.Set.new()
ret.init(expr.get_object(), expr.get_name(), value)
return ret
@@ -723,15 +771,15 @@ func _is_checking():
var ret = ESCGrammarExprs.Is.new()
ret.init(expr, null, _previous())
return ret
- else:
- var state_expr = _expression()
- if state_expr is ESCParseError:
- return state_expr
+ var state_expr = _expression()
- var ret = ESCGrammarExprs.Is.new()
- ret.init(expr, state_expr, null)
- return ret
+ if state_expr is ESCParseError:
+ return state_expr
+
+ var ret = ESCGrammarExprs.Is.new()
+ ret.init(expr, state_expr, null)
+ return ret
return expr
@@ -777,6 +825,9 @@ func _finish_call(callee: ESCGrammarExpr):
var paren = _consume(ESCTokenType.TokenType.RIGHT_PAREN, "Expect ')' after arguments.")
+ if paren is ESCParseError:
+ return paren
+
#if paren.get_type() != ESCTokenType.TokenType.NEWLINE:
# return _error(ESCTokenType.TokenType.NEWLINE, "Expect NEWLINE after arguments.")
@@ -817,7 +868,13 @@ func _primary():
if expr is ESCParseError:
return expr
- _consume(ESCTokenType.TokenType.RIGHT_PAREN, "Expect ')' after expression.")
+ var paren = _consume(
+ ESCTokenType.TokenType.RIGHT_PAREN,
+ "Expect ')' after expression."
+ )
+
+ if paren is ESCParseError:
+ return paren
var ret = ESCGrammarExprs.Grouping.new()
ret.init(expr)
@@ -826,30 +883,52 @@ func _primary():
return _error(_peek(), "Expect expression.")
-func _var_declaration() -> ESCGrammarStmt:
+func _var_declaration():
var name = _consume(ESCTokenType.TokenType.IDENTIFIER, "Expect variable name.")
var initializer: ESCGrammarExpr = null
if _match(ESCTokenType.TokenType.EQUAL):
- initializer = _expression()
+ var initializer_expr = _expression()
+
+ if initializer_expr is ESCParseError:
+ return initializer_expr
+
+ initializer = initializer_expr
+
+ var newline = _consume(
+ ESCTokenType.TokenType.NEWLINE,
+ "Expect newline after variable declaration."
+ )
- _consume(ESCTokenType.TokenType.NEWLINE, "Expect newline after variable declaration.")
+ if newline is ESCParseError:
+ return newline
var ret = ESCGrammarStmts.Var.new()
ret.init(name, initializer)
return ret
-func _global_declaration() -> ESCGrammarStmt:
+func _global_declaration():
var name = _consume(ESCTokenType.TokenType.IDENTIFIER, "Expect global variable name.")
var initializer: ESCGrammarExpr = null
if _match(ESCTokenType.TokenType.EQUAL):
- initializer = _expression()
+ var initializer_expr = _expression()
- _consume(ESCTokenType.TokenType.NEWLINE, "Expect newline after global variable declaration.")
+ if initializer_expr is ESCParseError:
+ return initializer_expr
+
+ initializer = initializer_expr
+
+ var newline = _consume(
+ ESCTokenType.TokenType.NEWLINE,
+ "Expect newline after global variable declaration."
+ )
+
+ if newline is ESCParseError:
+ return newline
var ret = ESCGrammarStmts.Global.new()
ret.init(name, initializer)
@@ -864,11 +943,11 @@ func _peek() -> ESCToken:
return _tokens[_current]
-func _match(tokenTypes) -> bool:
- if not tokenTypes is Array:
- tokenTypes = [tokenTypes]
+func _match(token_types) -> bool:
+ if not token_types is Array:
+ token_types = [token_types]
- for type in tokenTypes:
+ for type in token_types:
if _check(type):
_advance()
return true
@@ -876,12 +955,15 @@ func _match(tokenTypes) -> bool:
return false
-func _match_in_order(tokenTypes) -> bool:
- if not tokenTypes is Array:
- tokenTypes = [tokenTypes]
+func _match_in_order(token_types) -> bool:
+ if not token_types is Array:
+ token_types = [token_types]
- for type in tokenTypes:
+ var start := _current
+
+ for type in token_types:
if not _check(type):
+ _current = start
return false
_advance()
@@ -889,21 +971,21 @@ func _match_in_order(tokenTypes) -> bool:
return true
-func _consume(tokenType, message: String):
- if _check(tokenType):
+func _consume(token_type, message: String):
+ if _check(token_type):
return _advance()
return _error(_peek(), message)
-func _check(tokenTypes) -> bool:
- if not tokenTypes is Array:
- tokenTypes = [tokenTypes]
+func _check(token_types) -> bool:
+ if not token_types is Array:
+ token_types = [token_types]
if _at_end():
return false
- for type in tokenTypes:
+ for type in token_types:
if _peek().get_type() == type:
return true
@@ -911,9 +993,9 @@ func _check(tokenTypes) -> bool:
# This turns the parser into an LL(2).
-func _check_next(tokenTypes) -> bool:
- if not tokenTypes is Array:
- tokenTypes = [tokenTypes]
+func _check_next(token_types) -> bool:
+ if not token_types is Array:
+ token_types = [token_types]
if _at_end():
return false
@@ -924,7 +1006,7 @@ func _check_next(tokenTypes) -> bool:
_current -= 1
return false
- for type in tokenTypes:
+ for type in token_types:
if _peek().get_type() == type:
_current -= 1
return true
@@ -959,16 +1041,27 @@ func _error(token: ESCToken, message: String) -> ESCParseError:
func _synchronize() -> void:
+ if _check(ESCTokenType.TokenType.DEDENT):
+ return
+
_advance()
while not _at_end():
- if _previous().get_type() == ESCTokenType.TokenType.NEWLINE:
+ if _previous().get_type() == ESCTokenType.TokenType.NEWLINE \
+ or _previous().get_type() == ESCTokenType.TokenType.DEDENT:
return
match _peek().get_type():
+ ESCTokenType.TokenType.COLON,\
+ ESCTokenType.TokenType.GLOBAL,\
ESCTokenType.TokenType.VAR,\
ESCTokenType.TokenType.IF,\
ESCTokenType.TokenType.WHILE,\
+ ESCTokenType.TokenType.PASS,\
+ ESCTokenType.TokenType.STOP,\
+ ESCTokenType.TokenType.QUESTION_BANG,\
+ ESCTokenType.TokenType.BREAK,\
+ ESCTokenType.TokenType.DONE,\
ESCTokenType.TokenType.RETURN:
return
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_resolver.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_resolver.gd
index 2b32b35..d356b85 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_resolver.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_resolver.gd
@@ -1,8 +1,8 @@
## Resolves script variables to ensure that scoping rules are obeyed.
##
## The class will throw errors if scoping rules are broken or otherwise not followed.
-extends RefCounted
class_name ESCResolver
+extends RefCounted
var _interpreter
@@ -136,7 +136,7 @@ func visit_while_stmt(stmt: ESCGrammarStmts.While):
## #### Returns[br]
## [br]
## Returns nothing.
-func visit_pass_stmt(stmt: ESCGrammarStmts.Pass):
+func visit_pass_stmt(_stmt: ESCGrammarStmts.Pass):
pass
@@ -151,7 +151,7 @@ func visit_pass_stmt(stmt: ESCGrammarStmts.Pass):
## #### Returns[br]
## [br]
## Returns nothing.
-func visit_stop_stmt(stmt: ESCGrammarStmts.Stop):
+func visit_stop_stmt(_stmt: ESCGrammarStmts.Stop):
pass
@@ -186,7 +186,7 @@ func visit_var_stmt(stmt: ESCGrammarStmts.Var):
## #### Returns[br]
## [br]
## Returns nothing.
-func visit_global_stmt(stmt: ESCGrammarStmts.Global):
+func visit_global_stmt(_stmt: ESCGrammarStmts.Global):
pass
@@ -237,7 +237,7 @@ func visit_break_stmt(stmt: ESCGrammarStmts.Break):
## #### Returns[br]
## [br]
## Returns nothing.
-func visit_done_stmt(stmt: ESCGrammarStmts.Done):
+func visit_done_stmt(_stmt: ESCGrammarStmts.Done):
pass
@@ -270,7 +270,7 @@ func visit_dialog_stmt(stmt: ESCGrammarStmts.Dialog):
## #### Returns[br]
## [br]
## Returns nothing.
-func visit_literal_expr(expr: ESCGrammarExprs.Literal):
+func visit_literal_expr(_expr: ESCGrammarExprs.Literal):
pass
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_scanner.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_scanner.gd
index 08642a9..6591c48 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_scanner.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_scanner.gd
@@ -3,8 +3,8 @@
## This is the first link in the ASHES compiler toolchain, and takes in ASHES
## script as text in order to provide a list of tokens to be handled by the
## ASHES parser.
-extends RefCounted
class_name ESCScanner
+extends RefCounted
## In ASHES, an identifier starting with `$` is interpreted as a lookup in
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_script_builder.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_script_builder.gd
index 46ba408..26dacce 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_script_builder.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_script_builder.gd
@@ -1,7 +1,7 @@
## Convenience class to help build scripts in code for use with the ASHES compiler
## toolchain. Loosely follows the Builder pattern.
-extends RefCounted
class_name ESCScriptBuilder
+extends RefCounted
# Not really a "Builder" pattern per se, but helps with readability.
diff --git a/addons/escoria-core/game/core-scripts/esc/compiler/esc_token.gd b/addons/escoria-core/game/core-scripts/esc/compiler/esc_token.gd
index ec95d98..bd99d73 100644
--- a/addons/escoria-core/game/core-scripts/esc/compiler/esc_token.gd
+++ b/addons/escoria-core/game/core-scripts/esc/compiler/esc_token.gd
@@ -3,11 +3,11 @@
##
## This class contains any information required by the parser but also carries
## with it associated information useful for debugging and tracing purposes.
-extends RefCounted
class_name ESCToken
+extends RefCounted
-var _tokenType: int: # ESCScanner.TokenType enum
+var _token_type: int: # ESCScanner.TokenType enum
get = get_type
var _lexeme: String:
get = get_lexeme
@@ -38,8 +38,8 @@ var _filename: String:
## #### Returns[br]
## [br]
## Returns nothing.
-func init(tokenType: int, lexeme: String, literal, source: String, line: int, filename: String) -> void:
- _tokenType = tokenType
+func init(token_type: int, lexeme: String, literal, source: String, line: int, filename: String) -> void:
+ _token_type = token_type
_lexeme = lexeme
_literal = literal
_source = source
@@ -85,7 +85,7 @@ func get_source() -> String:
## [br]
## Returns the type of token this is; will be a value from `ESCTokenType.TokenType`. (`int`)
func get_type() -> int:
- return _tokenType
+ return _token_type
## The literal value of the token, if it exists.[br]
@@ -141,4 +141,4 @@ func get_filename() -> String:
func _to_string() -> String:
- return ESCTokenType.get_token_type_name(_tokenType) + " " + str(_lexeme) + " " + (str(_literal) if _literal else "")
+ return ESCTokenType.get_token_type_name(_token_type) + " " + str(_lexeme) + " " + (str(_literal) if _literal else "")
diff --git a/addons/escoria-core/game/core-scripts/esc/esc_action_manager.gd b/addons/escoria-core/game/core-scripts/esc/esc_action_manager.gd
index 7bf4a37..8e5c596 100644
--- a/addons/escoria-core/game/core-scripts/esc/esc_action_manager.gd
+++ b/addons/escoria-core/game/core-scripts/esc/esc_action_manager.gd
@@ -1,11 +1,7 @@
## Manages actions currently being carried out.
## @MANAGER
-extends Resource
class_name ESCActionManager
-
-
-## ESCPlayer resource
-const ESCPlayer = preload("res://addons/escoria-core/game/core-scripts/esc_player.gd")
+extends Resource
## The current action verb was changed.[br]
@@ -39,10 +35,10 @@ signal action_input_state_changed
## (I) -> AWAITING_VERB_OR_ITEM -> AWAITING_ITEM -> AWAITING_TARGET_ITEM -> COMPLETED -> (E)[br]
## or[br]
## (I) -> AWAITING_VERB_OR_ITEM -> AWAITING_VERB -> AWAITING_VERB_CONFIRMATION -> COMPLETED -> (E)
-enum ACTION_INPUT_STATE {
+enum ActionInputState {
AWAITING_VERB_OR_ITEM, ## Initial state
AWAITING_ITEM, ## After initial state, verb is defined
- AWAITING_TARGET_ITEM, ## Item defined requires combine, waiting for target
+ AWAITING_TARGET_ITEM, ## Item defined requires a target object
AWAITING_VERB, ## After initial state, item is defined
AWAITING_VERB_CONFIRMATION, ## Item was defined first, next verb, need verb confirmation
COMPLETED ## Final state
@@ -65,6 +61,9 @@ enum ACTION {
# Basic required internal actions
+## ESCPlayer resource
+const ESCPlayer = preload("res://addons/escoria-core/game/core-scripts/esc_player.gd")
+
## Action (event) triggered when a character has reached a destination.
const ACTION_ARRIVED = "arrived"
@@ -85,7 +84,7 @@ var current_tool: ESCObject
var current_target: ESCObject
## Current action input state.
-var action_state = ACTION_INPUT_STATE.AWAITING_VERB_OR_ITEM:
+var action_state = ActionInputState.AWAITING_VERB_OR_ITEM:
set = set_action_input_state
@@ -103,7 +102,7 @@ var action_state = ACTION_INPUT_STATE.AWAITING_VERB_OR_ITEM:
## [br]
## Returns nothing.
func do(action: int, params: Array = [], can_interrupt: bool = false) -> void:
- if escoria.current_state == escoria.GAME_STATE.DEFAULT:
+ if escoria.current_state == escoria.GameState.DEFAULT:
match action:
ACTION.BACKGROUND_CLICK:
if can_interrupt:
@@ -172,6 +171,7 @@ func do(action: int, params: Array = [], can_interrupt: bool = false) -> void:
var trigger_id = params[0]
var object_id = params[1]
var trigger_in_verb = params[2]
+ var trigger_object := escoria.object_manager.get_object(trigger_id)
escoria.logger.info(
self,
"trigger_in on trigger %s activated by %s." % [
@@ -179,12 +179,17 @@ func do(action: int, params: Array = [], can_interrupt: bool = false) -> void:
object_id
]
)
- if trigger_in_verb in escoria.object_manager.get_object(trigger_id).events:
- escoria.event_manager.queue_event(
- escoria.object_manager.get_object(trigger_id).events[
- trigger_in_verb
- ]
+
+ if not _is_object_actionable(trigger_object):
+ escoria.logger.info(
+ self,
+ "Trigger %s is not actionable. Skipping trigger_in." % trigger_id
)
+ return
+
+ var trigger_in_event = _get_trigger_event(trigger_object, trigger_in_verb, object_id)
+ if trigger_in_event:
+ escoria.event_manager.queue_event(trigger_in_event)
else:
escoria.logger.info(
self,
@@ -195,6 +200,7 @@ func do(action: int, params: Array = [], can_interrupt: bool = false) -> void:
var trigger_id = params[0]
var object_id = params[1]
var trigger_out_verb = params[2]
+ var trigger_object := escoria.object_manager.get_object(trigger_id)
escoria.logger.info(
self,
"trigger_out on trigger %s activated by %s." % [
@@ -202,12 +208,21 @@ func do(action: int, params: Array = [], can_interrupt: bool = false) -> void:
object_id
]
)
- if trigger_out_verb in escoria.object_manager.get_object(trigger_id).events:
- escoria.event_manager.queue_event(
- escoria.object_manager.get_object(trigger_id).events[
- trigger_out_verb
- ]
+
+ if not _is_object_actionable(trigger_object):
+ escoria.logger.info(
+ self,
+ "Trigger %s is not actionable. Skipping trigger_out." % trigger_id
)
+ return
+
+ var trigger_out_event = _get_trigger_event(
+ trigger_object,
+ trigger_out_verb,
+ object_id
+ )
+ if trigger_out_event:
+ escoria.event_manager.queue_event(trigger_out_event)
else:
escoria.logger.info(
self,
@@ -220,12 +235,19 @@ func do(action: int, params: Array = [], can_interrupt: bool = false) -> void:
self,
"Action received: '%s' with params %s." % [action, params]
)
- elif escoria.current_state == escoria.GAME_STATE.WAIT:
+ elif escoria.current_state == escoria.GameState.WAIT:
pass
- elif escoria.current_state == escoria.GAME_STATE.LOADING:
+ elif escoria.current_state == escoria.GameState.LOADING:
pass
+func _get_trigger_event(trigger_object: ESCObject, trigger_verb: String, object_id: String):
+ if trigger_object.has_event_with_target(trigger_verb, object_id):
+ return trigger_object.get_event_with_target(trigger_verb, object_id)
+
+ return trigger_object.get_event_with_target(trigger_verb)
+
+
## Sets the current state of action input. ## Parameters[br]
## [br]
## #### Parameters[br]
@@ -242,7 +264,7 @@ func set_action_input_state(p_state) -> void:
action_input_state_changed.emit()
-## Sets the current action verb. ## Parameters[br]
+## Sets the current action verb.
## [br]
## #### Parameters[br]
## [br]
@@ -259,10 +281,10 @@ func set_current_action(action: String) -> void:
current_action = action
- if action_state == ACTION_INPUT_STATE.AWAITING_VERB_OR_ITEM:
- set_action_input_state(ACTION_INPUT_STATE.AWAITING_ITEM)
- elif action_state == ACTION_INPUT_STATE.AWAITING_VERB:
- set_action_input_state(ACTION_INPUT_STATE.AWAITING_VERB_CONFIRMATION)
+ if action_state == ActionInputState.AWAITING_VERB_OR_ITEM:
+ set_action_input_state(ActionInputState.AWAITING_ITEM)
+ elif action_state == ActionInputState.AWAITING_VERB:
+ set_action_input_state(ActionInputState.AWAITING_VERB_CONFIRMATION)
action_changed.emit()
@@ -278,7 +300,7 @@ func set_current_action(action: String) -> void:
## Returns nothing.
func clear_current_action() -> void:
set_current_action("")
- set_action_input_state(ACTION_INPUT_STATE.AWAITING_VERB_OR_ITEM)
+ set_action_input_state(ActionInputState.AWAITING_VERB_OR_ITEM)
action_changed.emit()
@@ -294,10 +316,10 @@ func clear_current_action() -> void:
func clear_current_tool() -> void:
current_tool = null
current_target = null
- if action_state == ACTION_INPUT_STATE.AWAITING_VERB:
- set_action_input_state(ACTION_INPUT_STATE.AWAITING_VERB_OR_ITEM)
- elif action_state == ACTION_INPUT_STATE.AWAITING_TARGET_ITEM:
- set_action_input_state(ACTION_INPUT_STATE.AWAITING_ITEM)
+ if action_state == ActionInputState.AWAITING_VERB:
+ set_action_input_state(ActionInputState.AWAITING_VERB_OR_ITEM)
+ elif action_state == ActionInputState.AWAITING_TARGET_ITEM:
+ set_action_input_state(ActionInputState.AWAITING_ITEM)
## Checks if the specified action is valid and returns the associated event; otherwise, we see if there's a "fallback" event and use that if necessary and, if not, we return no event as there's nothing to do. or there's a reason not to run an event.[br]
@@ -326,9 +348,9 @@ func _get_event_to_queue(
var event_to_return = null
- # If we're using an action which item requires to combine
+ # If the action makes this item wait for a second target object.
if target.node is ESCItem \
- and action in target.node.combine_when_selected_action_is_in:
+ and action in target.node.actions_requiring_target_object:
# Check if object is in inventory or is not required to be in inventory to be used
if escoria.inventory_manager.inventory_has(target.global_id) or not target.node.use_from_inventory_only:
@@ -343,28 +365,19 @@ func _get_event_to_queue(
do_combine = false
if do_combine:
- var action_name = action
- if combine_with.global_id:
- action_name += " " + combine_with.global_id
-
- var combine_name = action
- if combine_with.global_id:
- combine_name += " " + target.global_id
-
- if _has_event_with_target(target.events, action_name, combine_with.global_id):
- event_to_return = target.events[action_name]
-
- elif _has_event_with_target(combine_with.events, combine_name, target.global_id)\
+ if target.has_event_with_target(action, combine_with.global_id):
+ event_to_return = target.get_event_with_target(action, combine_with.global_id)
+ elif combine_with.has_event_with_target(action, target.global_id)\
and not combine_with.node.combine_is_one_way:
- event_to_return = combine_with.events[combine_name]
+ event_to_return = combine_with.get_event_with_target(action, target.global_id)
else:
# Check to see if there isn't a "fallback" action to
# run before we declare this a failure.
if escoria.action_default_script \
- and escoria.action_default_script.events.has(action):
+ and escoria.action_default_script.has_event_with_target(action):
- event_to_return = escoria.action_default_script.events[action]
+ event_to_return = escoria.action_default_script.get_event_with_target(action)
else:
var errors = [
"Attempted to execute action '%s' between item %s and item %s" % [
@@ -414,17 +427,14 @@ func _get_event_to_queue(
+ "but item must be in inventory."
)
else:
- if _check_target_has_proper_action(target, action):
-# ##SAVEGAME
-# if target.events[action].is_completed:
-# target.events[action].is_completed = false
-# target.events[action].from_statement_id = 0
- event_to_return = target.events[action]
+ if target.has_event_with_target(action):
+ # We only deal with the action here as long as it doesn't have a target associated with it.
+ event_to_return = target.get_event_with_target(action)
elif escoria.action_default_script \
- and escoria.action_default_script.events.has(action):
+ and escoria.action_default_script.has_event_with_target(action):
# If there's a "fallback" action to run, return it
- event_to_return = escoria.action_default_script.events[action]
+ event_to_return = escoria.action_default_script.get_event_with_target(action)
else:
escoria.logger.warn(
self,
@@ -438,21 +448,23 @@ func _get_event_to_queue(
return event_to_return
-## Check to make sure `target` contains the specific `action`. If `target` has an entry for `action` that also requires a target itself (e.g. `:use "wrench"`), then we return `false` as combinations are handled elsewhere.[br]
+## Check to make sure `target_object` contains the specific `action`. If `target_object` has an entry for `action` that also requires a target itself (e.g. `:use "wrench"`), then we return `false` as targeted item actions are handled elsewhere.[br]
+## [br]
+## (The "target" in `target_object` is of a different meaning than the optional "target" in an event.)
## [br]
## #### Parameters[br]
## [br]
## | Name | Type | Description | Required? |[br]
## |:-----|:-----|:------------|:----------|[br]
-## |target|`ESCObject`|`ESCObject` whose events we are to check to see if `action` has a corresponding event.|yes|[br]
+## |target_object|`ESCObject`|`ESCObject` whose events we are to check to see if `action` has a corresponding event.|yes|[br]
## |action|`String`|The action to check.|yes|[br]
## [br]
## #### Returns[br]
## [br]
## Returns a `bool` value. (`bool`)
-func _check_target_has_proper_action(target: ESCObject, action: String) -> bool:
- if target.events.has(action):
- if target.events[action].get_target():
+func _check_target_has_proper_action(target_object: ESCObject, action: String) -> bool:
+ if target_object.has_event_with_target(action):
+ if target_object.get_event_with_target(action).get_target():
return false
return true
@@ -460,26 +472,6 @@ func _check_target_has_proper_action(target: ESCObject, action: String) -> bool:
return false
-## Determines whether the specified events dictionary contains an event with the specified event name and event target, e.g. :give "filled_out_form".[br]
-## [br]
-## #### Parameters[br]
-## [br]
-## | Name | Type | Description | Required? |[br]
-## |:-----|:-----|:------------|:----------|[br]
-## |events_dict|`Dictionary`|dictionary with events to check.|yes|[br]
-## |event_name|`String`|the event name to search for.|yes|[br]
-## |event_target|`String`|the target for the specified event to check.|yes|[br]
-## [br]
-## #### Returns[br]
-## [br]
-## Returns nothing.
-func _has_event_with_target(events_dict: Dictionary, event_name: String, event_target: String):
- var event = events_dict.get(event_name)
- if event == null:
- return false
- return true
-
-
## Runs the specified event.[br]
## [br]
## #### Parameters[br]
@@ -523,7 +515,6 @@ func perform_walk(
destination,
is_fast: bool = false
) -> void:
- # Walk to Position2D.
if destination is Vector2:
var walk_context = ESCWalkContext.new(
null,
@@ -578,29 +569,9 @@ func perform_inputevent_on_object(
event: InputEvent,
default_action: bool = false
) -> void:
- """
- This algorithm:
- - validates the requested action
- - grabs the corresponding event for the action, if available
- - makes the player move to the clicked object location, if needed
- (if it is located in the room for example) and wait for reaching.
- - when reached, performs an action depending on current defined action
- * no current action defined: do nothing else
- * current action defined:
- * item requires no combination: perform the current action
- on the item
- * item requires combination: check the status of the combination
- A combination requires 3 elements to fulfill:
- 1/ a verb action
- 2/ a first "tool" (item to use)
- 3/ a second "tool" (item to use ON)
- Whatever the user inputs to fulfill the combination (this is
- determined by gamedev in his game.gd script)
- - combination not fulfilled: no not perform until fulfilled
- - combination fulfilled: perform the combination.
- * else do nothing, except if default_action is requested.
- In this case, perform the default_action on the item.
- """
+ # This validates the requested action, resolves the corresponding event,
+ # moves the player when needed, and only performs targeted item actions
+ # after verb + source item + target item have all been selected.
escoria.logger.info(
self,
@@ -612,30 +583,30 @@ func perform_inputevent_on_object(
var dont_interact = false
# We need to have the new action input state BEFORE initiating the player
- # move so we determine now if the object clicked will require a combination
+ # move so we determine now if the object clicked requires a target object
# depending on the used action verb.
var tool_just_set = _set_tool_and_action(obj, default_action)
- var need_combine = _check_item_needs_combine()
+ var requires_target_object = _check_item_requires_target_object()
# If the clicked item is not in the inventory and the current tool was not set,
# then this is our first item, make it the tool.
if (not escoria.inventory_manager.inventory_has(obj.global_id) and not current_tool) \
- or (current_tool and not need_combine):
+ or (current_tool and not requires_target_object):
current_tool = obj
- # Else, if we have a tool and combination required, this is our second item,
+ # Else, if we have a tool and a target is required, this is our second item,
# make it the target.
- elif need_combine and not tool_just_set:
+ elif requires_target_object and not tool_just_set:
current_target = obj
# Update the action input state
- if action_state == ACTION_INPUT_STATE.AWAITING_TARGET_ITEM and current_target:
- set_action_input_state(ACTION_INPUT_STATE.COMPLETED)
- elif action_state == ACTION_INPUT_STATE.AWAITING_ITEM and \
- not need_combine:
- set_action_input_state(ACTION_INPUT_STATE.COMPLETED)
- elif action_state == ACTION_INPUT_STATE.AWAITING_ITEM and \
- need_combine and not tool_just_set:
- set_action_input_state(ACTION_INPUT_STATE.AWAITING_TARGET_ITEM)
+ if action_state == ActionInputState.AWAITING_TARGET_ITEM and current_target:
+ set_action_input_state(ActionInputState.COMPLETED)
+ elif action_state == ActionInputState.AWAITING_ITEM and \
+ not requires_target_object:
+ set_action_input_state(ActionInputState.COMPLETED)
+ elif action_state == ActionInputState.AWAITING_ITEM and \
+ requires_target_object and not tool_just_set:
+ set_action_input_state(ActionInputState.AWAITING_TARGET_ITEM)
var event_to_queue: ESCGrammarStmts.Event = null
@@ -648,19 +619,19 @@ func perform_inputevent_on_object(
# If so, and if the object is not in the inventory, we need to run the arrived action.
elif current_action in ["", ACTION_WALK] and not escoria.inventory_manager.inventory_has(obj.global_id):
event_to_queue = _get_event_to_queue(ACTION_ARRIVED, obj)
- # If the current action is set and different from "walk"/unset, we need to check for combinations.
+ # If the current action is set and different from "walk"/unset, check for targeted actions.
elif not current_action in ["", ACTION_WALK]:
- # If clicked object needs a combination, and current target is set, then perform the combination.
- if need_combine and current_target:
+ # If the selected tool requires a target and a target is set, run the targeted action.
+ if requires_target_object and current_target:
event_to_queue = _get_event_to_queue(current_action, current_tool, current_target)
- # If clicked object needs a combination then we need to wait for the target.
- elif need_combine:
- set_action_input_state(ACTION_INPUT_STATE.AWAITING_TARGET_ITEM)
+ # If the selected tool requires a target, wait for the player to choose one.
+ elif requires_target_object:
+ set_action_input_state(ActionInputState.AWAITING_TARGET_ITEM)
# If object is in inventory make it current tool.
if escoria.inventory_manager.inventory_has(obj.global_id):
current_tool = obj
return
- # If clicked object doesn't need a combination, then we simply run the action.
+ # If clicked object doesn't need a target, then we simply run the action.
else:
event_to_queue = _get_event_to_queue(current_action, obj)
@@ -707,8 +678,8 @@ func perform_inputevent_on_object(
"Player could not reach destination coordinates %s. " % str(destination_position) \
+ "Any requested action for %s will not fire." % obj.global_id
)
- if escoria.event_manager.EVENT_CANT_REACH in obj.events:
- escoria.event_manager.queue_event(obj.events[escoria.event_manager.EVENT_CANT_REACH])
+ if obj.has_event_with_target(escoria.event_manager.EVENT_CANT_REACH):
+ escoria.event_manager.queue_event(obj.get_event_with_target(escoria.event_manager.EVENT_CANT_REACH))
else:
escoria.logger.info(
self,
@@ -723,6 +694,8 @@ func perform_inputevent_on_object(
func _telekinetic_applies_to(event: ESCGrammarStmts.Event) -> bool:
+ if event == null:
+ return false
if event.get_flags_with_conditions().has("TK"):
var tk_flag_condition = event.get_flags_with_conditions().get("TK")
@@ -772,7 +745,7 @@ func _set_tool_and_action(obj: ESCObject, default_action: bool):
# Check if current_action and current_tool are already set
if current_action and current_tool:
if not current_action in escoria.action_manager \
- .current_tool.node.combine_when_selected_action_is_in:
+ .current_tool.node.actions_requiring_target_object:
current_tool = obj
tool_just_set = true
elif default_action:
@@ -780,13 +753,13 @@ func _set_tool_and_action(obj: ESCObject, default_action: bool):
current_action = obj.node.default_action_inventory
else:
current_action = obj.node.default_action
- elif current_action in obj.node.combine_when_selected_action_is_in:
+ elif current_action in obj.node.actions_requiring_target_object:
current_tool = obj
tool_just_set = true
return tool_just_set
-## Checks if object requires a combination with another, according to currently selected action verb (or check with default action of the item).[br]
+## Checks if the current tool requires a target object for the selected action verb.[br]
## [br]
## #### Parameters[br]
## [br]
@@ -795,10 +768,10 @@ func _set_tool_and_action(obj: ESCObject, default_action: bool):
## #### Returns[br]
## [br]
## Returns a `bool` value. (`bool`)
-func _check_item_needs_combine() -> bool:
+func _check_item_requires_target_object() -> bool:
return current_action \
and current_tool \
- and current_action in current_tool.node.combine_when_selected_action_is_in
+ and current_action in current_tool.node.actions_requiring_target_object
## Makes the player character walk towards the clicked item. Returns the resulting walk context.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/esc_command_registry.gd b/addons/escoria-core/game/core-scripts/esc/esc_command_registry.gd
index fdea1ec..a5d5a3b 100644
--- a/addons/escoria-core/game/core-scripts/esc/esc_command_registry.gd
+++ b/addons/escoria-core/game/core-scripts/esc/esc_command_registry.gd
@@ -1,6 +1,6 @@
## A registry of ASHES command objects.
-extends RefCounted
class_name ESCCommandRegistry
+extends RefCounted
## The registry of registered commands.
@@ -54,5 +54,4 @@ func load_command(command_name: String) -> ESCBaseCommand:
func is_command_or_control_pressed(command_name: String) -> ESCBaseCommand:
if self.registry.has(command_name):
return self.registry[command_name]
- else:
- return self.load_command(command_name)
+ return self.load_command(command_name)
diff --git a/addons/escoria-core/game/core-scripts/esc/esc_compiler.gd b/addons/escoria-core/game/core-scripts/esc/esc_compiler.gd
index c029f47..5ed051f 100644
--- a/addons/escoria-core/game/core-scripts/esc/esc_compiler.gd
+++ b/addons/escoria-core/game/core-scripts/esc/esc_compiler.gd
@@ -1,6 +1,6 @@
## Compiler for the ASHES language.
-extends Resource
class_name ESCCompiler
+extends Resource
## This must match `ESCProjectSettingsManager.COMMAND_DIRECTORIES`.
@@ -140,10 +140,21 @@ func _compiler_shim(source: String, filename: String = "", associated_global_id:
if not had_error:
for ps in parsed_statements:
- var event_name = ps.get_event_name()
- if ps.get_target() != null:
- event_name += " " + ps.get_target_name()
- script.events[event_name] = ps
+ if script.has_event_with_target(
+ ps.get_event_name(),
+ ps.get_target_name()
+ ):
+ ESCSafeLogging.log_error(
+ self,
+ "Duplicate event '%s' with target '%s'." % [
+ ps.get_event_name(),
+ ps.get_target_name()
+ ]
+ )
+ had_error = true
+ continue
+
+ script.events.add(ps)
return script
@@ -184,7 +195,7 @@ func load_esc_file(path: String, associated_global_id: String = "") -> ESCScript
## #### Returns[br]
## [br]
## Returns a `ESCScript` value. (`ESCScript`)
-func compile(script: String, path: String = "") -> ESCScript:
+func compile(script: String, _path: String = "") -> ESCScript:
return _compiler_shim(script)
diff --git a/addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd b/addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd
index 47fda29..a1e35dc 100644
--- a/addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd
+++ b/addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd
@@ -4,8 +4,8 @@
## the foreground channel _front, but additional event queues can be added as required.
## Additionally, events can be scheduled to be queued in the future.
## @MANAGER
-extends Node
class_name ESCEventManager
+extends Node
## Emitted when the event has begun execution.[br]
@@ -111,18 +111,15 @@ var events_queue: Dictionary = {
## Currently running event in background channels.
var _running_events: Dictionary = {}
+## Runtime interpreters currently owned by the event manager, keyed by channel.
+var _runtime_interpreters: Dictionary = {}
+
## Those commands that are currently running per channel.
var _running_commands: Dictionary = {}
-## Channel currently being processed.
-var _current_channel: String = ""
-
## Whether an event can be played on a specific channel.
var _channels_state: Dictionary = {}
-## Whether we're currently waiting for an async event to complete, per channel.
-var _yielding: Dictionary = {}
-
## Whether we're currently changing the scene.
var _changing_scene: bool = false: set = set_changing_scene
@@ -171,15 +168,10 @@ func _ready():
## [br]
## Returns nothing.
func _process(delta: float) -> void:
- var channel_yielding: bool
-
for channel_name in events_queue.keys():
- channel_yielding = _yielding.get(channel_name, false)
-
- if events_queue[channel_name].size() == 0 or channel_yielding:
+ if events_queue[channel_name].size() == 0:
continue
if is_channel_free(channel_name):
- _current_channel = channel_name
_channels_state[channel_name] = false
_running_events[channel_name] = \
events_queue[channel_name].pop_front()
@@ -193,15 +185,15 @@ func _process(delta: float) -> void:
if not _running_events[channel_name].source.is_empty()
else "(unknown)"
)
- if not _running_events[channel_name].is_connected(
- "finished", _on_event_finished
+ if not _running_events[channel_name].finished.is_connected(
+ _on_event_finished.bind(channel_name)
):
_running_events[channel_name].finished.connect(
_on_event_finished.bind(channel_name),
CONNECT_ONE_SHOT
)
- if not _running_events[channel_name].is_connected(
- "interrupted", _on_event_finished
+ if not _running_events[channel_name].interrupted.is_connected(
+ _on_event_finished.bind(channel_name)
):
_running_events[channel_name].interrupted.connect(
_on_event_finished.bind(channel_name),
@@ -231,27 +223,9 @@ func _process(delta: float) -> void:
#var rc = _running_events[channel_name].run()
#escoria.interpreter.reset()
#var resolver: ESCResolver = ESCResolver.new(escoria.interpreter)
- var interpreter: ESCInterpreter = ESCInterpreterFactory.create_interpreter()
- var resolver: ESCResolver = ESCResolver.new(interpreter)
var event = _running_events[channel_name]
- escoria.logger.trace(
- self,
- "Processing event '%s' from script '%s'." \
- % [
- event.get_name().get_lexeme(),
- event.get_name().get_filename() if event.get_name().get_filename() else "<internal>"
- ]
- )
-
- resolver.resolve(event)
-
- var rc = await interpreter.interpret(event)
-
- #if rc is GDScriptFunctionState:
- #_yielding[channel_name] = true
- #rc = await (rc, "completed")
- #_yielding[channel_name] = false
+ call_deferred("_run_event_on_channel", channel_name, event)
for event in self.scheduled_events:
(event as ESCScheduledEvent).timeout -= delta
@@ -260,6 +234,32 @@ func _process(delta: float) -> void:
self.events_queue[CHANNEL_FRONT].append(event.event)
+func _run_event_on_channel(channel_name: String, event: ESCGrammarStmts.Event) -> void:
+ var interpreter: ESCInterpreter = ESCInterpreterFactory.create_runtime_interpreter(channel_name)
+ _runtime_interpreters[channel_name] = interpreter
+ var resolver: ESCResolver = ESCResolver.new(interpreter)
+
+ escoria.logger.trace(
+ self,
+ "Processing event '%s' from script '%s'." \
+ % [
+ event.get_name().get_lexeme(),
+ event.get_name().get_filename() if event.get_name().get_filename() else "<internal>"
+ ]
+ )
+
+ resolver.resolve(event)
+ await interpreter.interpret(event)
+
+ # Runtime interpreters are owned per channel run. Clean up the local
+ # instance once interpretation has concluded without touching any newer
+ # interpreter another concurrent channel run may have registered.
+ interpreter.cleanup()
+
+ if _runtime_interpreters.get(channel_name) == interpreter:
+ _runtime_interpreters.erase(channel_name)
+
+
## Queues a new event based on input from an ASHES command, most likely `queue_event`.[br]
## [br]
## #### Parameters[br]
@@ -281,23 +281,24 @@ func queue_event_from_esc(script_object: ESCScript, event: String,
return ESCExecution.RC_WONT_QUEUE
if channel == CHANNEL_FRONT:
- queue_event(script_object.events[event])
+ queue_event(script_object.get_event_with_target(event))
else:
queue_background_event(
channel,
- script_object.events[event]
+ script_object.get_event_with_target(event)
)
+
if block:
if channel == CHANNEL_FRONT:
var rc = await self.event_finished
while rc[1] != event:
rc = await self.event_finished
return rc[0]
- else:
- var rc = await self.background_event_finished
- while rc[1] != event and rc[2] != channel:
- rc = await self.background_event_finished
- return rc[0]
+
+ var rc = await self.background_event_finished
+ while rc[1] != event or rc[2] != channel:
+ rc = await self.background_event_finished
+ return rc[0]
return ESCExecution.RC_OK
@@ -335,12 +336,13 @@ func queue_event(event: ESCGrammarStmts.Event, force: bool = false, as_first = f
escoria.logger.debug(self, message % [event.get_event_name(), CHANNEL_FRONT])
return
- elif _is_event_running(event, CHANNEL_FRONT):
+
+ if _is_event_running(event, CHANNEL_FRONT):
# Don't queue the same event if it's already running.
escoria.logger.debug(
self,
"Event %s already running in channel '%s'. Won't be queued."
- % [event.get_event_name(), CHANNEL_FRONT]
+ % [event.get_event_name(), CHANNEL_FRONT]
)
return
@@ -388,27 +390,10 @@ func queue_background_event(channel_name: String, event: ESCGrammarStmts.Event)
if not channel_name in events_queue:
events_queue[channel_name] = []
- # Don't queue the same event more than once in a row.
- var last_event = _get_last_event_queued(channel_name)
-
- # Check the queue first to see if appending the event will result in
- # consecutive occurrences of the event. If not, be sure to check if the same
- # event is currently running.
- if last_event != null and last_event.get_event_name() == event.get_event_name():
- var message = "Event '%s' is already the most-recently queued event in channel '%s'." + \
- " Won't be queued again."
-
- escoria.logger.debug(self, message % [event.get_event_name(), channel_name])
- return
- elif _is_event_running(event, CHANNEL_FRONT):
- # Don't queue the same event if it's already running.
- escoria.logger.debug(
- self,
- "Event %s already running in channel '%s'. Won't be queued."
- % [event.get_event_name(), channel_name]
- )
-
- return
+ # Background channels are fed by explicit ASHES commands, so repeated queue
+ # requests are intentional. This includes back-to-back enqueues of the same
+ # event name and enqueuing the same event again while an earlier run is
+ # already active on the channel.
events_queue[channel_name].append(event)
@@ -482,8 +467,23 @@ func interrupt(exceptions: PackedStringArray = [], stop_walking = true) -> void:
## [br]
## Returns nothing.
func interrupt_channel(channel_name: String) -> void:
- for command in _running_commands.get(channel_name, []):
- command.interrupt()
+ var running_event: ESCGrammarStmts.Event = _running_events.get(channel_name, null)
+
+ # Interrupt the channel's running event directly so any in-flight command is
+ # reached even if command bookkeeping has not yet been updated for this frame.
+ if running_event != null:
+ escoria.logger.debug(
+ self,
+ "Interrupting running event %s in channel %s..."
+ % [running_event.get_event_name(), channel_name]
+ )
+ running_event.interrupt()
+ _channels_state[channel_name] = true
+
+ # Clear any queued follow-up events on the same channel so the interrupt has
+ # the same effect as the broader interrupt path, but scoped to one channel.
+ if events_queue.has(channel_name):
+ events_queue[channel_name].clear()
## Clears the event queues.[br]
@@ -563,10 +563,6 @@ func set_changing_scene(p_is_changing_scene: bool) -> void:
])
-# This probably won't work since _current_channel could have changed after
-# a yielding command resumes. Also the event itself isn't logged, just the command,
-# creating a problem.
-
## Adds a currently-running command to the current channel.[br]
## [br]
## #### Parameters[br]
@@ -579,10 +575,13 @@ func set_changing_scene(p_is_changing_scene: bool) -> void:
## [br]
## Returns nothing.
func add_running_command(command: ESCCommand) -> void:
- if _running_commands.get(_current_channel, []) == []:
- _running_commands[_current_channel] = [command]
+ if command.channel_name.is_empty():
+ return
+
+ if _running_commands.get(command.channel_name, []) == []:
+ _running_commands[command.channel_name] = [command]
else:
- _running_commands[_current_channel].append(command)
+ _running_commands[command.channel_name].append(command)
## Removes the specified command from the current channel.[br]
@@ -597,8 +596,11 @@ func add_running_command(command: ESCCommand) -> void:
## [br]
## Returns nothing.
func running_command_finished(command: ESCCommand) -> void:
- if command in _running_commands[_current_channel]:
- _running_commands[_current_channel].erase(command)
+ if command.channel_name.is_empty():
+ return
+
+ if command in _running_commands.get(command.channel_name, []):
+ _running_commands[command.channel_name].erase(command)
## The event finished running.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/esc_globals_manager.gd b/addons/escoria-core/game/core-scripts/esc/esc_globals_manager.gd
index 61c1e38..e9b86af 100644
--- a/addons/escoria-core/game/core-scripts/esc/esc_globals_manager.gd
+++ b/addons/escoria-core/game/core-scripts/esc/esc_globals_manager.gd
@@ -3,8 +3,8 @@
## The ASHES global state is basically simply a dictionary of keys with
## values. Values can be bool, integer or strings.
## @MANAGER
-extends Resource
class_name ESCGlobalsManager
+extends Resource
## Emitted when a global has changed.[br]
@@ -123,6 +123,19 @@ func get_global(key: String):
return null
+## Returns a snapshot copy of all currently stored globals.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `Dictionary` containing the current global state. (`Dictionary`)
+func get_globals() -> Dictionary:
+ return _globals.duplicate(true)
+
+
## Filters the globals and return all matching keys and their values as a dictionary. Check out [the Godot docs](https://docs.godotengine.org/en/stable/classes/class_string.html#class-string-method-match) for the pattern format.[br]
## [br]
## #### Parameters[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/esc_inventory_manager.gd b/addons/escoria-core/game/core-scripts/esc/esc_inventory_manager.gd
index 944aead..a6016ce 100644
--- a/addons/escoria-core/game/core-scripts/esc/esc_inventory_manager.gd
+++ b/addons/escoria-core/game/core-scripts/esc/esc_inventory_manager.gd
@@ -1,7 +1,7 @@
## A manager for inventory objects.
## @MANAGER
-extends Resource
class_name ESCInventoryManager
+extends Resource
## Checks whether the player has an inventory item.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/esc_object_manager.gd b/addons/escoria-core/game/core-scripts/esc/esc_object_manager.gd
index 047fcab..de2e671 100644
--- a/addons/escoria-core/game/core-scripts/esc/esc_object_manager.gd
+++ b/addons/escoria-core/game/core-scripts/esc/esc_object_manager.gd
@@ -1,7 +1,7 @@
## A manager for ESC objects.
## @MANAGER
-extends Resource
class_name ESCObjectManager
+extends Resource
## Reserved camera object.
@@ -94,6 +94,23 @@ func _init() -> void:
current_room_key = ESCRoomObjectsKey.new()
+## Clears all room-specific object and terrain state while preserving reserved
+## objects managed by Escoria itself.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func reset_game_state() -> void:
+ room_objects.clear()
+ room_objects.push_back(reserved_objects_container)
+ room_terrains.clear()
+ current_room_key = ESCRoomObjectsKey.new()
+
+
## Updates which object manager room is to be treated as the currently active one.[br]
## [br]
## #### Parameters[br]
@@ -133,7 +150,6 @@ func set_current_room(room: ESCRoom) -> void:
## Returns nothing.
func register_object(object: ESCObject, room: ESCRoom = null, force: bool = false, \
auto_unregister: bool = true) -> void:
-
if object.global_id.is_empty():
object.global_id = str(object.node.get_path()).split("/root/", false)[0]
object.node.global_id = object.global_id
@@ -193,7 +209,7 @@ func register_object(object: ESCObject, room: ESCRoom = null, force: bool = fals
return
# Object exists in room, set it to is last state (if different from
# "default")
- elif object.node is ESCItem and _object_exists_in_room(object, room_key):
+ if object.node is ESCItem and _object_exists_in_room(object, room_key):
# Object is already known, set its state to last known state
object.set_state(get_object(object.global_id).state)
@@ -201,16 +217,26 @@ func register_object(object: ESCObject, room: ESCRoom = null, force: bool = fals
# forcing the registration, since we don't know if this object will be
# overwritten ("forced") in the future and, if it is, if it's set to
# auto-unregister or not. In most cases, objects are set to auto unregister.
- if object.node.tree_exited.is_connected(unregister_object):
- object.node.tree_exited.disconnect(unregister_object)
+ if not object.unregister_object_callback.is_null() and \
+ object.node.tree_exited.is_connected(object.unregister_object_callback):
+ object.node.tree_exited.disconnect(object.unregister_object_callback)
if force:
+ var existing_object: ESCObject = _get_room_objects_objects(room_key).get(object.global_id)
+ if existing_object != null and is_instance_valid(existing_object.node):
+ if existing_object.node.tree_exited.is_connected(
+ existing_object.unregister_object_callback
+ ):
+ existing_object.node.tree_exited.disconnect(
+ existing_object.unregister_object_callback
+ )
# If this ID already exists and we're about to overwrite it, do the
# safe thing and unregister the old object first
unregister_object_by_global_id(object.global_id, room_key)
if auto_unregister:
- object.node.tree_exited.connect(unregister_object.bind(object, room_key))
+ object.unregister_object_callback = unregister_object.bind(object, room_key)
+ object.node.tree_exited.connect(object.unregister_object_callback)
if "is_interactive" in object.node and object.node.is_interactive:
object.interactive = true
@@ -352,13 +378,13 @@ func get_object(global_id: String, room: ESCRoom = null) -> ESCObject:
if global_id in RESERVED_OBJECTS:
if reserved_objects_container.objects.has(global_id):
return reserved_objects_container.objects[global_id]
- else:
- escoria.logger.warn(
- self,
- "Reserved object with global id %s not found in object manager!"
- % global_id
- )
- return null
+
+ escoria.logger.warn(
+ self,
+ "Reserved object with global id %s not found in object manager!"
+ % global_id
+ )
+ return null
var room_key: ESCRoomObjectsKey
@@ -382,24 +408,24 @@ func get_object(global_id: String, room: ESCRoom = null) -> ESCObject:
if objects.has(global_id):
return objects[global_id]
- else:
- escoria.logger.warn(
- self,
- "Object with global id %s in room instance (%s, %s) not found. This can be safely ignored if a room was being searched for."
- % [global_id, room_key.room_global_id, room_key.room_instance_id]
- )
- if escoria.inventory_manager.inventory_has(global_id):
- # item is in the inventory and may be registered to a different room
- for single_room in room_objects:
- # these are arrays of the objects still registered for each room
- if single_room.objects.has(global_id):
- escoria.logger.info(
- self,
- "Object with global id %s found in room instance (%s, %s) through the inventory."
- % [global_id, room_key.room_global_id, room_key.room_instance_id]
- )
- return single_room.objects[global_id]
- return null
+
+ escoria.logger.warn(
+ self,
+ "Object with global id %s in room instance (%s, %s) not found. This can be safely ignored if a room was being searched for."
+ % [global_id, room_key.room_global_id, room_key.room_instance_id]
+ )
+ if escoria.inventory_manager.inventory_has(global_id):
+ # item is in the inventory and may be registered to a different room
+ for single_room in room_objects:
+ # these are arrays of the objects still registered for each room
+ if single_room.objects.has(global_id):
+ escoria.logger.info(
+ self,
+ "Object with global id %s found in room instance (%s, %s) through the inventory."
+ % [global_id, room_key.room_global_id, room_key.room_instance_id]
+ )
+ return single_room.objects[global_id]
+ return null
## Removes an object from the registry.[br]
@@ -422,7 +448,7 @@ func unregister_object(object: ESCObject, room_key: ESCRoomObjectsKey) -> void:
escoria.logger.debug(
self,
"Unable to unregister object. " +
- "Object with global ID %s room (%s, %s) not found. If this was "
+ "Object with global ID '%s' room (%s, %s) not found. If this was "
% [
"?" if object == null else object.global_id,
room_key.room_global_id,
@@ -430,7 +456,6 @@ func unregister_object(object: ESCObject, room_key: ESCRoomObjectsKey) -> void:
] +
"part of a 'forced' registration, ignore this warning."
)
-
return
var room_objects = _get_room_objects_objects(room_key)
@@ -701,3 +726,31 @@ func _erase_room(room_key: ESCRoomObjectsKey) -> void:
if _compare_container_to_key(room_container, room_key):
room_objects.erase(room_container)
return
+
+
+## Disconnect all existing items from registered ESCObjects for the given room.
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |room_key|`ESCRoomObjectsKey`|The key representing the desired room in the object manager array|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func disconnect_tree_exit_for_room_items(room_key: ESCRoomObjectsKey) -> void:
+ #var _room_objects = _get_room_objects_objects(room_key)
+ var i: int = 0
+ for room_container in room_objects:
+ if room_container.room_global_id == room_key.room_global_id \
+ and room_container.room_instance_id == room_key.room_instance_id:
+ for object in room_container.objects:
+ var ro: ESCObject = room_objects[i].objects[object]
+ if is_instance_valid(ro.node) and \
+ ro.node.tree_exited.is_connected(ro.unregister_object_callback):
+ ro.node.tree_exited.disconnect(ro.unregister_object_callback)
+ break
+ else:
+ i += 1
+ continue
diff --git a/addons/escoria-core/game/core-scripts/esc/esc_room_manager.gd b/addons/escoria-core/game/core-scripts/esc/esc_room_manager.gd
index 1a077a9..60042f7 100644
--- a/addons/escoria-core/game/core-scripts/esc/esc_room_manager.gd
+++ b/addons/escoria-core/game/core-scripts/esc/esc_room_manager.gd
@@ -1,7 +1,7 @@
## A manager for rooms in a game.
## @MANAGER
-extends Resource
class_name ESCRoomManager
+extends Resource
## The room manager scene.
## Manages room loading and keeps in memory
@@ -116,6 +116,23 @@ func change_scene_to_file(room_path: String, enable_automatic_transitions: bool)
room_scene.add_child(escoria.game_scene)
room_scene.move_child(escoria.game_scene, 0)
room_scene.game = escoria.game_scene
+
+ # If the scene we're loading is the same as the current one, disconnect
+ # the tree_exit signal on the existing items (that was connected when
+ # items got registered in Object Manager) so that objects for new items
+ # of the new room don't get unregistered.
+ var current_scene: Node = escoria.main.current_scene
+ var new_room_global_id: String = room_scene.global_id \
+ if not room_scene.global_id.is_empty() else room_scene.name
+ if is_instance_valid(current_scene):
+ var current_room_global_id: String = \
+ current_scene.global_id if not current_scene.global_id.is_empty() \
+ else current_scene.name
+ if new_room_global_id == current_room_global_id:
+ escoria.object_manager.disconnect_tree_exit_for_room_items(
+ escoria.object_manager.current_room_key
+ )
+
escoria.main.set_scene(room_scene)
# We know the scene has been loaded. Make its global ID available for
@@ -341,7 +358,7 @@ func _perform_script_events(room: ESCRoom) -> int:
get_class())
escoria.event_manager.queue_event(
- script_transition_out_compiled.events[escoria.event_manager.EVENT_TRANSITION_OUT],
+ script_transition_out_compiled.get_event_with_target(escoria.event_manager.EVENT_TRANSITION_OUT),
true
)
@@ -381,14 +398,6 @@ func _perform_script_events(room: ESCRoom) -> int:
if not is_instance_valid(room.player):
room.player = room.player_scene.instantiate()
room.add_child(room.player)
- escoria.object_manager.register_object(
- ESCObject.new(
- room.player.global_id,
- room.player
- ),
- room,
- true
- )
if escoria.globals_manager.has(
escoria.room_manager.GLOBAL_ANIMATION_RESOURCES
@@ -424,12 +433,19 @@ func _perform_script_events(room: ESCRoom) -> int:
# Add new camera to scene being prepared.
if room.player_camera:
- room.remove_child(room.player_camera)
+ room.remove_child(room.player_camera) # This unregisters the camera from object manager
room.player_camera.queue_free()
var new_player_camera: ESCCamera = escoria.resource_cache.get_resource(
CAMERA_SCENE_PATH
).instantiate()
- new_player_camera.register()
+ escoria.object_manager.register_object(
+ ESCObject.new(
+ ESCObjectManager.CAMERA,
+ new_player_camera
+ ),
+ null,
+ true
+ )
room.player_camera = new_player_camera
# We must first set the camera limits, and then worry about subsequent
@@ -530,10 +546,10 @@ func _perform_script_events(room: ESCRoom) -> int:
var script_transition_in = escoria.esc_compiler.compile(transition_in_script.build())
escoria.event_manager.queue_event(
- script_transition_in.events[escoria.event_manager.EVENT_TRANSITION_IN]
+ script_transition_in.get_event_with_target(escoria.event_manager.EVENT_TRANSITION_IN)
)
- if not escoria.current_state == escoria.GAME_STATE.LOADING:
+ if not escoria.current_state == escoria.GameState.LOADING:
var ready_event_added: bool = false
# Run the ready event, if there is one.
ready_event_added = _run_script_event(escoria.event_manager.EVENT_READY, room)
@@ -593,14 +609,15 @@ func _run_script_event(event_name: String, room: ESCRoom):
room.compiled_script = \
escoria.esc_compiler.load_esc_file(room.esc_script)
- if room.compiled_script.events.has(event_name):
+ if room.compiled_script.has_event_with_target(event_name):
+ var event = room.compiled_script.get_event_with_target(event_name)
escoria.logger.debug(
self,
"Queuing room script event %s " % event_name +
"composed of %s statements."
- % room.compiled_script.events[event_name].get_num_statements_in_block()
+ % event.get_num_statements_in_block()
)
- escoria.event_manager.queue_event(room.compiled_script.events[event_name], true)
+ escoria.event_manager.queue_event(event, true)
return true
- else:
- return false
+
+ return false
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_base_command.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_base_command.gd
index c46714d..e7f27e5 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_base_command.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_base_command.gd
@@ -1,8 +1,8 @@
## Abstract base class for every ESC command.
##
## Extending classes have to override the configure and run function
-extends Resource
class_name ESCBaseCommand
+extends Resource
## The filename from which the relevant command is being called, if available.
@@ -11,6 +11,12 @@ var filename: String = ""
## The line number from the file the relevant command is being called from.
var line_number: int = 0
+## Whether the command is allowed to be ran in a room's :ready event during a
+## savegame loading. This is especially the case for sound control commands,
+## for which the Save Manager resumes from the recorded position.[br]
+## Set this to `false` in the `_init()` method of such commands when necessary.
+var run_in_ready_allowed_during_savegame_loading: bool = true
+
## A descriptor (contained in `ESCCommandArgumentDescriptor`) of the arguments of this command.[br]
## [br]
@@ -60,7 +66,7 @@ func validate(arguments: Array) -> bool:
## #### Returns[br]
## [br]
## Returns a `int` value. (`int`)
-func run(command_params: Array) -> int:
+func run(_command_params: Array) -> int:
raise_error(self, "Command %s did not override run. Please implement a run() function." % get_command_name())
return 0
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_camera_base_command.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_camera_base_command.gd
index 54af190..88088a8 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_camera_base_command.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_camera_base_command.gd
@@ -6,8 +6,8 @@
## [br]
## None.
## [br]
-extends ESCBaseCommand
class_name ESCCameraBaseCommand
+extends ESCBaseCommand
## Generates a log entry when attempting to move the camera to an invalid position.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_command.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_command.gd
index 4a5cb76..1008a14 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_command.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_command.gd
@@ -1,8 +1,8 @@
## Used to properly setup and execute commands, i.e. those that extend `ESCBaseCommand`.
##
## Despite its name, `ESCCommand` is more of a container and orchestrator than an actual command.
-extends ESCStatement
class_name ESCCommand
+extends ESCStatement
## The name of this command.
@@ -11,10 +11,15 @@ var name: String
## Parameters for this command
var parameters: Array = []
+## Channel where this command's owning event is running.
+var channel_name: String = ""
+
## The token generated by the parser containing info for debugging (e.g. line number,
## script filename, etc.).
var parser_token: ESCToken = null
+var _running_command_impl: ESCBaseCommand = null
+
## A `Dictionary` containing the data needed when saving a game.[br]
## [br]
@@ -85,36 +90,50 @@ func _command_exists() -> bool:
## Returns a `int` value. (`int`)
func run() -> int:
var command_object = escoria.command_registry.load_command(self.name)
+ _running_command_impl = command_object
_decorate_for_debugging(command_object)
if command_object == null:
+ _running_command_impl = null
return ESCExecution.RC_ERROR
- else:
- var argument_descriptor = command_object.configure()
- _decorate_for_debugging(argument_descriptor)
+ var argument_descriptor = command_object.configure()
- var prepared_arguments = argument_descriptor.prepare_arguments(
- self.parameters
- )
+ _decorate_for_debugging(argument_descriptor)
+
+ var prepared_arguments = argument_descriptor.prepare_arguments(
+ self.parameters
+ )
- if command_object.validate(prepared_arguments):
+ if command_object.validate(prepared_arguments):
+ if escoria.save_manager.is_loading_game \
+ and escoria.event_manager.get_running_event(escoria.event_manager.CHANNEL_FRONT).get_event_name() == escoria.event_manager.EVENT_READY \
+ and not command_object.run_in_ready_allowed_during_savegame_loading:
escoria.logger.debug(
self,
- "Running command %s with parameters %s."
+ "Skipped command %s with parameters %s because it is not allowed to be ran from :ready event during savegame load."
% [self.name, prepared_arguments]
)
- escoria.event_manager.add_running_command(self)
- var rc = await command_object.run(prepared_arguments)
- escoria.event_manager.running_command_finished(self)
- escoria.logger.debug(
- self,
- "[%s] Return code: %d." % [self.name, rc]
- )
- return rc
- else:
- return ESCExecution.RC_ERROR
+ _running_command_impl = null
+ return ESCExecution.RC_WONT_QUEUE
+ escoria.logger.debug(
+ self,
+ "Running command %s with parameters %s."
+ % [self.name, prepared_arguments]
+ )
+ escoria.event_manager.add_running_command(self)
+ var rc = await command_object.run(prepared_arguments)
+ escoria.event_manager.running_command_finished(self)
+ _running_command_impl = null
+ escoria.logger.debug(
+ self,
+ "[%s] Return code: %d." % [self.name, rc]
+ )
+ return rc
+
+ _running_command_impl = null
+ return ESCExecution.RC_ERROR
## This function interrupts the command. If it was not started, it will not run. If it had already started, the execution will be considered as finished immediately and finish. If it had already finished, nothing will happen.[br]
@@ -128,9 +147,8 @@ func run() -> int:
## Returns nothing.
func interrupt():
_is_interrupted = true
- var command = escoria.command_registry.is_command_or_control_pressed(self.name)
- if command.has_method("interrupt"):
- command.interrupt()
+ if _running_command_impl and _running_command_impl.has_method("interrupt"):
+ _running_command_impl.interrupt()
## Override of built-in _to_string function to display the statement.[br]
## [br]
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_command_argument_descriptor.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_command_argument_descriptor.gd
index 44b3b77..8367f49 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_command_argument_descriptor.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_command_argument_descriptor.gd
@@ -1,6 +1,6 @@
## Describes the arguments of commands that extend `ESCBaseCommand`.
-extends RefCounted
class_name ESCCommandArgumentDescriptor
+extends RefCounted
## As the get_type command was deprecated with Godot 2.x w we need a way to determine
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_dialog.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_dialog.gd
index 5d48a98..032696f 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_dialog.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_dialog.gd
@@ -1,6 +1,6 @@
## Represents a dialog in Escoria.
-extends ESCStatement
class_name ESCDialog
+extends ESCStatement
## Avatar used in the dialog, if any.
@@ -12,6 +12,9 @@ var timeout: int = 0
## The dialog option to select when timeout is reached.
var timeout_option: int = 0
+## Total number of options authored in this dialog, including currently hidden options.
+var authored_options_count: int = 0
+
## A list of `ESCDialogOption`s.
var options: Array
@@ -32,7 +35,11 @@ func is_valid() -> bool:
"Avatar scene not found: %s." % self.avatar
)
return false
- if self.timeout_option > self.options.size() \
+ var option_count := self.authored_options_count
+ if option_count == 0:
+ option_count = self.options.size()
+
+ if self.timeout_option > option_count \
or self.timeout_option < 0:
escoria.logger.error(
self,
@@ -43,6 +50,33 @@ func is_valid() -> bool:
return true
+## Returns the currently visible option configured as the timeout default, if any.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns the currently visible timeout option as an `ESCDialogOption`, or `null` if the configured option is not visible.
+func get_timeout_option() -> ESCDialogOption:
+ var has_source_option_indexes := false
+
+ for option in self.options:
+ if option.source_option_index > 0:
+ has_source_option_indexes = true
+
+ if option.source_option_index == self.timeout_option:
+ return option
+
+ if not has_source_option_indexes \
+ and self.timeout_option > 0 \
+ and self.timeout_option <= self.options.size():
+ return self.options[self.timeout_option - 1]
+
+ return null
+
+
## Run this dialog. TODO: Although this method overrides its parent version, the return type here does NOT match the parent's signature. Consider either changing the parent's return type to be a `Variant`, or doing something to ensure greater consistency.[br]
## [br]
## #### Parameters[br]
@@ -58,7 +92,7 @@ func run():
"Starting dialog."
)
- escoria.current_state = escoria.GAME_STATE.DIALOG
+ escoria.current_state = escoria.GameState.DIALOG
if !escoria.dialog_player:
escoria.dialog_player = escoria.main.current_scene.get_node(
@@ -75,6 +109,6 @@ func run():
# If this is the case and the current level of dialog has a parent, it means
# it is still yielding and so will be shown again.
- escoria.current_state = escoria.GAME_STATE.DEFAULT
+ escoria.current_state = escoria.GameState.DEFAULT
return option
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_dialog_option.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_dialog_option.gd
index e3701e5..8737dbc 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_dialog_option.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_dialog_option.gd
@@ -1,17 +1,23 @@
## A single option used as part of a dialog.
##
## `ESCDialog` makes use of these when assembling an actual dialog in Escoria.
-extends ESCStatement
class_name ESCDialogOption
+extends ESCStatement
## Option text displayed in the HUD.
var option: String:
get = get_translated_option
+## Explicit translation key for the option text.
+var translation_key: String = ""
+
## Maps back to the parsed source option.
var source_option
+## One-based index of this option in its authored dialog block.
+var source_option_index: int = -1
+
## Whether this option is valid.
var _is_valid: bool:
set = set_is_valid,
@@ -28,6 +34,14 @@ var _is_valid: bool:
## [br]
## Returns the translated version of the option, if one exists; otherwise, the default text is returned. (`String`)
func get_translated_option() -> String:
+ if not translation_key.is_empty():
+ var translated_text = tr(translation_key)
+
+ if translation_key != translated_text:
+ return translated_text
+
+ return option
+
# Check if text has a key
if ":" in option:
var splitted_text = option.split(":")
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_event.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_event.gd
index 19a0075..bf9e92c 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_event.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_event.gd
@@ -12,18 +12,8 @@
## run once the room is visible.[br]
## - `:use <global id>` : Called from the current item when it is used with the item
## with specified by `<global id>`.
-extends ESCStatement
class_name ESCEvent
-
-## Regex identifying an ESC event.
-const REGEX = \
- '^:(?<name>[^|]+)( \\|\\s*(?<flags>( ' + \
- '(TK|NO_TT|NO_UI|NO_SAVE)' + \
- ')+))?$'
-
-## Prefix to identify this as an ESC event.
-const PREFIX = ":"
-
+extends ESCStatement
## Valid event flags:[br]
## - `TK`: stands for "telekinetic". It means the player won't walk over to
@@ -43,6 +33,14 @@ enum FLAGS {
NO_SAVE = 8
}
+## Regex identifying an ESC event.
+const REGEX = \
+ '^:(?<name>[^|]+)( \\|\\s*(?<flags>( ' + \
+ '(TK|NO_TT|NO_UI|NO_SAVE)' + \
+ ')+))?$'
+
+## Prefix to identify this as an ESC event.
+const PREFIX = ":"
## Name of the event.
var name: String
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_events_container.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_events_container.gd
new file mode 100644
index 0000000..0ef313e
--- /dev/null
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_events_container.gd
@@ -0,0 +1,52 @@
+## Container for storing events in Escoria, providing a consistent way to provide
+## visibility and access to events.
+class_name ESCEventsContainer
+extends RefCounted
+
+
+## The list of events being tracked.
+var events: Array = []
+
+
+## Adds an event to the container.[br]
+##[br]
+## #### Parameters[br]
+##[br]
+## - event: the event to be stored
+func add(event) -> void:
+ events.append(event)
+
+
+## Determines whether the specified events list contains an event with the[br]
+## specified event name and event target, e.g. `:give "filled_out_form"`[br]
+##[br]
+## #### Parameters[br]
+##[br]
+## - event_name: the event name to search for[br]
+## - event_target: the target for the specified event to check; may be null[br]
+##[br]
+## *Returns* true iff events contains an event matching both event_name and event_target
+func has_event_with_target(event_name: String, event_target = null) -> bool:
+ return is_instance_valid(get_event_with_target(event_name, event_target))
+
+
+## Returns the event matching the specified event name and target, e.g. `:give "filled_out_form"`[br]
+## if it exists; returns null otherwise.[br]
+##[br]
+## #### Parameters[br]
+##[br]
+## - event_name: the event name to search for[br]
+## - event_target: the target for the specified event to check; may be null[br]
+##[br]
+## *Returns* the event in `events` iff `events` contains an event matching both `event_name` and `event_target`;[br]
+## returns null otherwise.
+func get_event_with_target(event_name: String, event_target = null):
+ for event in events:
+ if event.get_event_name() == event_name:
+ if event_target:
+ if event.get_target_name() == event_target:
+ return event
+ else:
+ if not event.get_target_name():
+ return event
+ return null
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_events_container.gd.uid b/addons/escoria-core/game/core-scripts/esc/types/esc_events_container.gd.uid
new file mode 100644
index 0000000..2559148
--- /dev/null
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_events_container.gd.uid
@@ -0,0 +1 @@
+uid://ct6ek3jk6spbq
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_execution.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_execution.gd
index 21641e7..917895c 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_execution.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_execution.gd
@@ -1,6 +1,6 @@
## Class with enum containing the return codes returned by ASHES script functions.
-extends Resource
class_name ESCExecution
+extends Resource
## Execution result values.
## Return codes handled by events.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_object.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_object.gd
index 64d3596..eb975ae 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_object.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_object.gd
@@ -1,7 +1,10 @@
## Represents an object that is able to be handled by Escoria.
-extends RefCounted
class_name ESCObject
+extends RefCounted
+const ESC_EVENTS_CONTAINER_SCRIPT := preload(
+ "res://addons/escoria-core/game/core-scripts/esc/types/esc_events_container.gd"
+)
## Default object state.
const STATE_DEFAULT: String = "default"
@@ -21,12 +24,15 @@ var interactive: bool = true: get = _get_interactive, set = _set_interactive
var state: String = STATE_DEFAULT:
get = get_state
-## The events registered with the object.
-var events: Dictionary = {}
+## The events registered with the object
+var events = ESC_EVENTS_CONTAINER_SCRIPT.new()
## The node representing the object in the scene.
var node: Node
+## Variable that stores the Callable method to unregister the object from Object Manager
+var unregister_object_callback: Callable
+
## Initializes the ESCObject with a global id and node.[br]
## [br]
## #### Parameters[br]
@@ -142,8 +148,8 @@ func is_active() -> bool:
func _get_interactive() -> bool:
if is_instance_valid(self.node) and "is_interactive" in self.node:
return self.node.is_interactive
- else:
- return true
+
+ return true
## Sets the interactive value in the node.[br]
@@ -177,7 +183,7 @@ func _set_interactive(value: bool):
func get_save_data() -> Dictionary:
var save_data: Dictionary = {}
- if self.global_id == "player" and not is_instance_valid(self.node):
+ if self.node is ESCPlayer and not is_instance_valid(self.node):
return save_data
save_data["active"] = self.active
@@ -185,18 +191,59 @@ func get_save_data() -> Dictionary:
save_data["state"] = self.state
if is_instance_valid(self.node):
+ if self.node is ESCItem:
+ if self.node.animations != null:
+ save_data["animations_resource_path"] = (self.node as ESCItem).animations.resource_path
+ var animation_player = (self.node as ESCItem).get_animation_player()
+ if is_instance_valid(animation_player):
+ save_data["current_animation"] = animation_player.get_current_animation()
+
if self.node.get("is_movable") and self.node.is_movable:
save_data["global_transform"] = self.node.global_transform
save_data["last_deg"] = wrapi(self.node._movable._get_angle() - 90 + 1, 0, 360)
save_data["last_dir"] = self.node._movable.last_dir
+ save_data["speed"] = self.node.speed
if self.node.has_method("get_custom_data"):
save_data["custom_data"] = self.node.get_custom_data()
- if self.global_id in ["_music", "_sound", "_ambient"] and self.node.get("state"):
+ if self.global_id in [ESCObjectManager.MUSIC, ESCObjectManager.SOUND, ESCObjectManager.AMBIENT] and self.node.get("state"):
save_data["state"] = self.node.get("state")
- save_data["playback_position"] = self.node.get_playback_position()
+ if ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.SAVE_SOUNDS_PLAYBACK_POSITION
+ ):
+ save_data["playback_position"] = self.node.get_playback_position()
- if self.global_id == "_camera":
+ if self.global_id == ESCObjectManager.CAMERA:
save_data["target"] = self.node.get("_follow_target").global_id
+ var camera_limit_id = escoria.main.get_camera_limit_id_for_room(escoria.main.current_scene)
+ if camera_limit_id != null:
+ save_data["limit_id"] = camera_limit_id
return save_data
+
+
+## Determines whether the specified events list contains an event with the[br]
+## specified event name and event target, e.g. `:give "filled_out_form"`[br]
+##[br]
+## #### Parameters[br]
+##[br]
+## - event_name: the event name to search for[br]
+## - event_target: the target for the specified event to check; may be null[br]
+##[br]
+## *Returns* true iff events contains an event matching both event_name and event_target
+func has_event_with_target(event_name: String, event_target = null) -> bool:
+ return events.has_event_with_target(event_name, event_target)
+
+
+## Returns the event matching the specified event name and target, e.g. `:give "filled_out_form"`[br]
+## if it exists; returns null otherwise.[br]
+##[br]
+## #### Parameters[br]
+##[br]
+## - event_name: the event name to search for[br]
+## - event_target: the target for the specified event to check; may be null[br]
+##[br]
+## *Returns* the event in `events` iff `events` contains an event matching both `event_name` and `event_target`;[br]
+## returns null otherwise.
+func get_event_with_target(event_name: String, event_target = null):
+ return events.get_event_with_target(event_name, event_target)
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_room_container.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_room_container.gd
index 591d2b5..4c5bb80 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_room_container.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_room_container.gd
@@ -1,7 +1,7 @@
## Abstract base class for a container of Escoria entities specific to a room
## to be stored in and used by the Escoria object manager.
-extends RefCounted
class_name ESCRoomContainer
+extends RefCounted
## Designates whether the objects contained in this container are all reserved objects.
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_room_objects.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_room_objects.gd
index 4a617ce..e798e8e 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_room_objects.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_room_objects.gd
@@ -1,6 +1,6 @@
## Container for a room's `ESCObject`s stored in the object manager.
-extends ESCRoomContainer
class_name ESCRoomObjects
+extends ESCRoomContainer
## The hash of objects registered to the room.
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_room_objects_key.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_room_objects_key.gd
index b6f5ce4..0d303d0 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_room_objects_key.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_room_objects_key.gd
@@ -1,7 +1,7 @@
## Simple pair container to store a room's identifying information for use in
## the object manager.
-extends RefCounted
class_name ESCRoomObjectsKey
+extends RefCounted
## Contains the `global_id` of the room being represented by this key.
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_room_terrains.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_room_terrains.gd
index 153a49c..cefac5b 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_room_terrains.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_room_terrains.gd
@@ -1,6 +1,6 @@
## Container for `NavigationPolygon`s stored in the object manager.
-extends ESCRoomContainer
class_name ESCRoomTerrains
+extends ESCRoomContainer
## The hash of terrains registered to the room.
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_scheduled_event.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_scheduled_event.gd
index 736f241..8053366 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_scheduled_event.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_scheduled_event.gd
@@ -1,6 +1,6 @@
## Represents an event that is scheduled to run sometime in the future.
-extends RefCounted
class_name ESCScheduledEvent
+extends RefCounted
## The event to run when timeout is reached.
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_script.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_script.gd
index efca0f8..ba5b7d9 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_script.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_script.gd
@@ -1,11 +1,49 @@
## Represents a compiled ASHES script.
-extends Resource
class_name ESCScript
+extends Resource
+
+const ESC_EVENTS_CONTAINER_SCRIPT := preload(
+ "res://addons/escoria-core/game/core-scripts/esc/types/esc_events_container.gd"
+)
+
+## The events registered with the script.
+var events = ESC_EVENTS_CONTAINER_SCRIPT.new()
+
+##############
+# New interpreter stuff
+var parsed_events = []
-## A dictionary of `ESCEvent`s contained in this script.
-var events: Dictionary
## The name of the ASHES file parsed for this script, if this script was in fact
## loaded from a file (as opposed to being from an internal string).
var filename: String = ""
+
+##############
+
+
+## Determines whether the specified events list contains an event with the[br]
+## specified event name and event target, e.g. `:give "filled_out_form"`[br]
+##[br]
+## #### Parameters[br]
+##[br]
+## - event_name: the event name to search for[br]
+## - event_target: the target for the specified event to check; may be null[br]
+##[br]
+## *Returns* true iff events contains an event matching both event_name and event_target
+func has_event_with_target(event_name: String, event_target = null) -> bool:
+ return events.has_event_with_target(event_name, event_target)
+
+
+## Returns the event matching the specified event name and target, e.g. `:give "filled_out_form"`[br]
+## if it exists; returns null otherwise.[br]
+##[br]
+## #### Parameters[br]
+##[br]
+## - event_name: the event name to search for[br]
+## - event_target: the target for the specified event to check; may be null[br]
+##[br]
+## *Returns* the event in `events` iff `events` contains an event matching both `event_name` and `event_target`;[br]
+## returns null otherwise.
+func get_event_with_target(event_name: String, event_target = null):
+ return events.get_event_with_target(event_name, event_target)
diff --git a/addons/escoria-core/game/core-scripts/esc/types/esc_statement.gd b/addons/escoria-core/game/core-scripts/esc/types/esc_statement.gd
index 345dd28..5a8e800 100644
--- a/addons/escoria-core/game/core-scripts/esc/types/esc_statement.gd
+++ b/addons/escoria-core/game/core-scripts/esc/types/esc_statement.gd
@@ -5,8 +5,8 @@
## newer ASHES scripting language used by Escoria. Although this class is extended
## to facilitate the execution of actual commands and dialogs, the name may be
## a bit misleading and may eventually be renamed or refactored.
-extends RefCounted
class_name ESCStatement
+extends RefCounted
## Emitted when the event has finished running.[br]
@@ -136,7 +136,7 @@ func run() -> int:
)
if rc == ESCExecution.RC_REPEAT:
return await self.run()
- elif rc != ESCExecution.RC_OK:
+ if rc != ESCExecution.RC_OK:
final_rc = rc
current_statement.is_completed = true
break
diff --git a/addons/escoria-core/game/core-scripts/esc_animation_player.gd b/addons/escoria-core/game/core-scripts/esc_animation_player.gd
index 66b908e..807d6c1 100644
--- a/addons/escoria-core/game/core-scripts/esc_animation_player.gd
+++ b/addons/escoria-core/game/core-scripts/esc_animation_player.gd
@@ -1,7 +1,7 @@
## An abstraction class to expose the same animation methods for both
## AnimatedSprite and AnimationPlayer.
-extends Node
class_name ESCAnimationPlayer
+extends Node
## Signal emitted when the animation finished playing.[br]
@@ -28,7 +28,8 @@ var _animated_sprite: AnimatedSprite2D
var _is_animation_player: bool = false
## Currently running animation.
-var _current_animation: String = ""
+var _current_animation: String = "":
+ get = get_current_animation
## Create a new animation player.[br]
@@ -80,8 +81,7 @@ func _ready() -> void:
func get_animation() -> String:
if _is_animation_player:
return _animation_player.assigned_animation
- else:
- return _animated_sprite.animation
+ return _animated_sprite.animation
## A list of all animation names.[br]
@@ -96,8 +96,7 @@ func get_animation() -> String:
func get_animations() -> PackedStringArray:
if _is_animation_player:
return _animation_player.get_animation_list()
- else:
- return _animated_sprite.sprite_frames.get_animation_names()
+ return _animated_sprite.sprite_frames.get_animation_names()
## Whether the animation is playing.[br]
@@ -187,8 +186,7 @@ func play_backwards(name: String):
func has_animation(name: String) -> bool:
if _is_animation_player:
return _animation_player.has_animation(name)
- else:
- return _animated_sprite.sprite_frames.has_animation(name)
+ return _animated_sprite.sprite_frames.has_animation(name)
## Play an animation and directly skip to the end.[br]
@@ -203,6 +201,7 @@ func has_animation(name: String) -> bool:
## [br]
## Returns nothing.
func seek_end(name: String):
+ _current_animation = name
if _is_animation_player:
_animation_player.current_animation = name
_animation_player.seek(_animation_player.get_animation(name).length, true)
@@ -225,9 +224,9 @@ func seek_end(name: String):
func get_length(name: String) -> float:
if _is_animation_player:
return _animation_player.get_animation(name).length
- else:
- return _animated_sprite.sprite_frames.get_frame_count(name) - 1 * \
- _animated_sprite.sprite_frames.get_animation_speed(name)
+
+ return _animated_sprite.sprite_frames.get_frame_count(name) - 1 * \
+ _animated_sprite.sprite_frames.get_animation_speed(name)
## Return true if the ESCAnimationPlayer node is valid, ie. it has a valid player node.[br]
@@ -257,7 +256,7 @@ func is_valid() -> bool:
func _on_animation_finished(name: String):
if _is_animation_player and not _animation_player.get_animation(name).loop_mode != Animation.LOOP_NONE:
_animation_player.stop(true) # param here is to keep current state and
- # avoid resetting animation position to 0
+ # avoid resetting animation position to 0
elif not _animated_sprite.sprite_frames.get_animation_loop(name):
_animated_sprite.stop()
animation_finished.emit(name)
@@ -274,3 +273,16 @@ func _on_animation_finished(name: String):
## Returns nothing.
func _on_animation_finished_animated_sprite():
_on_animation_finished(_current_animation)
+
+
+## Gets the current animation name.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## A string containing the current animation name.
+func get_current_animation() -> String:
+ return _current_animation
diff --git a/addons/escoria-core/game/core-scripts/esc_background.gd b/addons/escoria-core/game/core-scripts/esc_background.gd
index fff871d..e0fb6d2 100644
--- a/addons/escoria-core/game/core-scripts/esc_background.gd
+++ b/addons/escoria-core/game/core-scripts/esc_background.gd
@@ -10,8 +10,8 @@
## is set over the whole scene, because its rect_size is then used to create the
## Area2D node under it. If the rect_size is wrongly set, the background may
## receive no input.
-extends TextureRect
class_name ESCBackground
+extends TextureRect
## The background was double clicked[br]
@@ -137,7 +137,7 @@ func _ready():
## [br]
## Returns nothing.
func _unhandled_input(event: InputEvent) -> void:
- var is_default_state = escoria.current_state == escoria.GAME_STATE.DEFAULT
+ var is_default_state = escoria.current_state == escoria.GameState.DEFAULT
if escoria.inputs_manager.try_custom_input_handler(event, is_default_state):
return
if not is_default_state:
diff --git a/addons/escoria-core/game/core-scripts/esc_dialog_location.gd b/addons/escoria-core/game/core-scripts/esc_dialog_location.gd
index 0d5c7c1..80c38f0 100644
--- a/addons/escoria-core/game/core-scripts/esc_dialog_location.gd
+++ b/addons/escoria-core/game/core-scripts/esc_dialog_location.gd
@@ -5,8 +5,8 @@
## automatically use an `ESCLocation` that is a child of the destination node.
## Commands like `turn_to`--which are not movement-based--will ignore child
## `ESCLocation`s and refer to the parent node.
-extends ESCLocation
class_name ESCDialogLocation
+extends ESCLocation
## Whether this object's class is the same as given string class name.[br]
@@ -15,10 +15,10 @@ class_name ESCDialogLocation
## [br]
## | Name | Type | Description | Required? |[br]
## |:-----|:-----|:------------|:----------|[br]
-## |p_classname|`String`|String class to compare against|yes|[br]
+## |p_classname|`String (4.6.x), StringName (4.7.x)`|Class name to compare against this location.|yes|[br]
## [br]
## #### Returns[br]
## [br]
## Returns a `bool` value. (`bool`)
-func is_class(p_classname: String) -> bool:
+func is_class(p_classname) -> bool:
return p_classname == "ESCDialogLocation"
diff --git a/addons/escoria-core/game/core-scripts/esc_exit.gd b/addons/escoria-core/game/core-scripts/esc_exit.gd
index 79db99a..b9851c5 100644
--- a/addons/escoria-core/game/core-scripts/esc_exit.gd
+++ b/addons/escoria-core/game/core-scripts/esc_exit.gd
@@ -11,8 +11,8 @@
## [br]
## The game character will automatically walk to an ``ESCLocation`` created as a
## child of an ``ESCExit`` node.
-extends ESCItem
class_name ESCExit
+extends ESCItem
## Path to the target scene to change to
@@ -72,9 +72,9 @@ func _ready():
## Returns nothing.
func _register_event():
if escoria.object_manager.has(self.global_id) and\
- not escoria.event_manager.EVENT_EXIT_SCENE in escoria.object_manager.get_object(
+ not escoria.object_manager.get_object(
self.global_id
- ).events:
+ ).has_event_with_target(escoria.event_manager.EVENT_EXIT_SCENE):
var exit_scene_script_builder: ESCScriptBuilder = ESCScriptBuilder.new()
@@ -94,7 +94,7 @@ func _register_event():
var exit_scene_event = \
escoria.esc_compiler.compile(exit_scene_script_builder.build(), get_class()) \
- .events[escoria.event_manager.EVENT_EXIT_SCENE]
+ .get_event_with_target(escoria.event_manager.EVENT_EXIT_SCENE)
escoria.object_manager.get_object(self.global_id) \
- .events[escoria.event_manager.EVENT_EXIT_SCENE] = exit_scene_event
+ .events.add(exit_scene_event)
diff --git a/addons/escoria-core/game/core-scripts/esc_game.gd b/addons/escoria-core/game/core-scripts/esc_game.gd
index 9a424ad..ae155bb 100644
--- a/addons/escoria-core/game/core-scripts/esc_game.gd
+++ b/addons/escoria-core/game/core-scripts/esc_game.gd
@@ -1,8 +1,8 @@
## Base class for ESC game scenes
## An extending class can be used in the project settings and is responsible
## for managing very basic game features and controls
-extends Node2D
class_name ESCGame
+extends Node2D
## Signal emitted when the user has confirmed the crash popup[br]
@@ -25,7 +25,7 @@ signal request_pause_menu
## Editor debug modes[br]
## NONE - No debugging[br]
## MOUSE_TOOLTIP_LIMITS - Visualize the tooltip limits
-enum EDITOR_GAME_DEBUG_DISPLAY {
+enum EditorGameDebugDisplay {
NONE, # No debugging
MOUSE_TOOLTIP_LIMITS # Visualize the tooltip limits
}
@@ -41,7 +41,7 @@ enum EDITOR_GAME_DEBUG_DISPLAY {
@export var mouse_tooltip_margin: float = 50.0
## Debug mode for the editor (None, Mouse Tooltips Limit)
-@export var editor_debug_mode: EDITOR_GAME_DEBUG_DISPLAY = EDITOR_GAME_DEBUG_DISPLAY.NONE:
+@export var editor_debug_mode: EditorGameDebugDisplay = EditorGameDebugDisplay.NONE:
set = _set_editor_debug_mode
## The Control node underneath which all UI must be placed.
@@ -143,10 +143,10 @@ func _ready():
func _draw():
if not Engine.is_editor_hint():
return
- if editor_debug_mode == EDITOR_GAME_DEBUG_DISPLAY.NONE:
+ if editor_debug_mode == EditorGameDebugDisplay.NONE:
return
- if editor_debug_mode == EDITOR_GAME_DEBUG_DISPLAY.MOUSE_TOOLTIP_LIMITS:
+ if editor_debug_mode == EditorGameDebugDisplay.MOUSE_TOOLTIP_LIMITS:
var mouse_limits: Rect2 = get_viewport_rect().grow(
-mouse_tooltip_margin
)
@@ -319,7 +319,7 @@ func left_double_click_on_bg(position: Vector2) -> void:
## #### Returns[br]
## [br]
## Returns nothing.
-func element_focused(element_id: String) -> void:
+func element_focused(_element_id: String) -> void:
pass
@@ -412,8 +412,8 @@ func left_double_click_on_item(
## [br]
## Returns nothing.
func left_click_on_inventory_item(
- inventory_item_global_id: String,
- event: InputEvent
+ _inventory_item_global_id: String,
+ _event: InputEvent
) -> void:
pass
@@ -431,8 +431,8 @@ func left_click_on_inventory_item(
## [br]
## Returns nothing.
func right_click_on_inventory_item(
- inventory_item_global_id: String,
- event: InputEvent
+ _inventory_item_global_id: String,
+ _event: InputEvent
) -> void:
pass
@@ -450,8 +450,8 @@ func right_click_on_inventory_item(
## [br]
## Returns nothing.
func left_double_click_on_inventory_item(
- inventory_item_global_id: String,
- event: InputEvent
+ _inventory_item_global_id: String,
+ _event: InputEvent
) -> void:
pass
@@ -467,7 +467,7 @@ func left_double_click_on_inventory_item(
## #### Returns[br]
## [br]
## Returns nothing.
-func inventory_item_focused(inventory_item_global_id: String) -> void:
+func inventory_item_focused(_inventory_item_global_id: String) -> void:
pass
@@ -521,7 +521,7 @@ func close_inventory():
## #### Returns[br]
## [br]
## Returns nothing.
-func mousewheel_action(direction: int):
+func mousewheel_action(_direction: int):
pass
@@ -557,7 +557,7 @@ func show_ui():
## [br]
## | Name | Type | Description | Required? |[br]
## |:-----|:-----|:------------|:----------|[br]
-## |p_editor_debug_mode|`int`|EDITOR_GAME_DEBUG_DISPLAY enum (int) value corresponding to the desired editor debug mode|yes|[br]
+## |p_editor_debug_mode|`int`|EditorGameDebugDisplay enum (int) value corresponding to the desired editor debug mode|yes|[br]
## [br]
## #### Returns[br]
## [br]
@@ -659,7 +659,7 @@ func hide_main_menu():
## #### Returns[br]
## [br]
## Returns nothing.
-func apply_custom_settings(custom_settings: Dictionary):
+func apply_custom_settings(_custom_settings: Dictionary):
pass
diff --git a/addons/escoria-core/game/core-scripts/esc_interaction_location.gd b/addons/escoria-core/game/core-scripts/esc_interaction_location.gd
index 4fe84fb..1f09e3d 100644
--- a/addons/escoria-core/game/core-scripts/esc_interaction_location.gd
+++ b/addons/escoria-core/game/core-scripts/esc_interaction_location.gd
@@ -5,8 +5,8 @@
## automatically use an `ESCLocation` that is a child of the destination node.[br]
## Commands like `turn_to`--which are not movement-based--will ignore child
## `ESCLocation`s and refer to the parent node.
-extends ESCLocation
class_name ESCInteractionLocation
+extends ESCLocation
## Whether this object's class is the same as given string class name.[br]
## [br]
@@ -14,10 +14,10 @@ class_name ESCInteractionLocation
## [br]
## | Name | Type | Description | Required? |[br]
## |:-----|:-----|:------------|:----------|[br]
-## |p_classname|`String`|String class to compare against|yes|[br]
+## |p_classname|`String (4.6.x), StringName (4.7.x)`|Class name to compare against this location.|yes|[br]
## [br]
## #### Returns[br]
## [br]
## Returns a `bool` value. (`bool`)
-func is_class(p_classname: String) -> bool:
+func is_class(p_classname) -> bool:
return p_classname == "ESCInteractionLocation"
diff --git a/addons/escoria-core/game/core-scripts/esc_item.gd b/addons/escoria-core/game/core-scripts/esc_item.gd
index 5f58b33..604addc 100644
--- a/addons/escoria-core/game/core-scripts/esc_item.gd
+++ b/addons/escoria-core/game/core-scripts/esc_item.gd
@@ -11,12 +11,8 @@
## chooses to "use" the exit - for example, saying a goodbye, or running a
## cutscene. Place a ``change_scene`` command inside this event to move the
## character to the next room.
-extends Area2D
class_name ESCItem
-
-
-## List of forbidden characters in global_ids
-const FORBIDDEN_CHARACTERS: String = "['\"]"
+extends Area2D
## Emitted when the mouse has entered this item[br]
@@ -79,6 +75,26 @@ signal mouse_right_clicked_item(global_id)
## [br]
signal arrived(walk_context)
+## Emitted when the item started talking.
+## [br]
+signal started_talking
+
+## Emitted when the item stopped talking.
+## [br]
+signal stopped_talking
+
+## Emitted when the item started walking.
+## [br]
+signal started_walking
+
+## Emitted when the item stopped walking.
+## [br]
+signal stopped_walking
+
+
+## List of forbidden characters in global_ids
+const FORBIDDEN_CHARACTERS: String = "['\"]"
+
## The global ID of this item
@export var global_id: String
@@ -108,10 +124,10 @@ signal arrived(walk_context)
## Default action to use if object is not in the inventory.
@export var default_action: String
-## If action used by player is in this list, the game will wait for a second
-## click on another item to combine objects together (typically
-## `USE <X> WITH <Y>`, `GIVE <X> TO <Y>`)
-@export var combine_when_selected_action_is_in: PackedStringArray = []
+## Actions in this list make the item act as a source object and wait for a
+## second clicked target object before running the action, typically
+## `USE <X> WITH <Y>` or `GIVE <X> TO <Y>`.
+@export var actions_requiring_target_object: PackedStringArray = []
## If enabled, the ASHES script may have an :exit_scene event to manage scene changes.
## For simple exits that do not require scripted actions, the ESCExit node may be
@@ -146,11 +162,9 @@ signal arrived(walk_context)
## If hover is enabled, applies this color modulation on the ESCItem sprite.
@export var hover_modulate: Color = Color.WHITE
-var _previous_color_modulate: Color
## If hover is enabled, replaces this ESCItem sprite texture by this one.
@export var hover_texture: Texture2D = null
-var _previous_texture: Texture2D = null
## If hover is enabled, applies this shader to the ESCItem sprite.
@export var hover_shader: ShaderMaterial = null
@@ -246,6 +260,12 @@ var _movable: ESCMovable = null
## The identified animation player
var _animation_player: ESCAnimationPlayer = null
+## Original sprite color modulation restored when hover ends.
+var _previous_color_modulate: Color
+
+## Original sprite texture restored when hover ends.
+var _previous_texture: Texture2D = null
+
## Whether to force regsitration with the object manager. Defaults to false.
var _force_registration: bool = false
@@ -285,6 +305,7 @@ func _ready():
if not escoria.event_manager.event_finished.is_connected(_update_terrain):
escoria.event_manager.event_finished.connect(_update_terrain)
+ _force_registration = true if escoria.save_manager.is_loading_game else false
escoria.object_manager.register_object(
ESCObject.new(
global_id,
@@ -523,7 +544,7 @@ func _unhandled_input(input_event: InputEvent) -> void:
event.position = get_global_mouse_position()
if event is InputEventMouseButton and event.is_pressed():
- if not escoria.current_state == escoria.GAME_STATE.DEFAULT:
+ if not escoria.current_state == escoria.GameState.DEFAULT:
ESCSafeLogging.log_info(
self,
"Current game state doesn't accept interactions."
@@ -550,10 +571,10 @@ func _unhandled_input(input_event: InputEvent) -> void:
## [br]
## #### Returns[br]
## [br]
-## Returns a `String` value containing configuration warnings joined by newlines. (`String`)
+## Returns a `PackedStringArray` value containing configuration warnings
func _get_configuration_warnings():
validate_animations(animations)
- return "\n".join(_scene_warnings)
+ return _scene_warnings
## Tests whether given point is inside a shape.[br]
## [br]
@@ -650,8 +671,8 @@ func set_animations(p_animations: ESCAnimationResource) -> void:
animations = p_animations
- if not animations.is_connected("changed", Callable(self, "_validate_animations")):
- animations.connect("changed", Callable(self, "_validate_animations"))
+ if not animations.changed.is_connected(validate_animations):
+ animations.changed.connect(validate_animations)
## The animation player node[br]
@@ -859,6 +880,7 @@ func teleport_to(target: Vector2) -> void:
## Returns nothing.
func walk_to(pos: Vector2, p_walk_context: ESCWalkContext = null) -> void:
if is_movable:
+ started_walking.emit()
_movable.walk_to(pos, p_walk_context)
else:
ESCSafeLogging.log_warn(
@@ -883,6 +905,7 @@ func stop_walking_now(to_target: bool = false) -> void:
var where: Vector2 = position
if to_target:
where = _movable.walk_destination
+ stopped_walking.emit()
_movable.walk_stop(where)
else:
ESCSafeLogging.log_warn(
@@ -931,11 +954,11 @@ func has_moved() -> bool:
func has_sprite() -> bool:
if _sprite_node != null:
return true
- else: # confirm
- for child in self.get_children():
- if child is AnimatedSprite2D or child is Sprite2D:
- return true
- return false
+
+ for child in self.get_children():
+ if child is AnimatedSprite2D or child is Sprite2D:
+ return true
+ return false
## Return the sprite node.[br]
## [br]
@@ -1093,6 +1116,7 @@ func start_talking():
animation_player.play(
animations.speaks[animation_direction].animation
)
+ started_talking.emit(animation_direction)
## Stop playing the talking animation[br]
@@ -1128,6 +1152,7 @@ func stop_talking():
animation_player.play(
animations.idles[0].animation
)
+ stopped_talking.emit()
## Replay the last idle animation[br]
@@ -1193,7 +1218,7 @@ func _detect_children() -> void:
## #### Returns[br]
## [br]
## Returns nothing.
-func _update_terrain(rc: int, event_name: String) -> void:
+func _update_terrain(_rc: int, event_name: String) -> void:
if is_movable:
_movable.update_terrain(event_name)
@@ -1261,15 +1286,15 @@ func _get_inventory_texture() -> Texture2D:
if c is TextureRect or c is Sprite2D:
return c.texture
return null
- else:
- return inventory_texture
+
+ return inventory_texture
func _get_inventory_texture_hovered() -> Texture2D:
if inventory_texture_hovered == null:
return _get_inventory_texture()
- else:
- return inventory_texture_hovered
+
+ return inventory_texture_hovered
## Checks whether the given ESCAnimationResource property array has all non-null entries, and adds to the scene's warnings if not[br]
## [br]
@@ -1307,8 +1332,8 @@ func _validate_animations_property_all_not_null(property: Array, property_name:
func _get_identifier_as_key_value() -> String:
if self.global_id:
return "global_id: %s" % self.global_id
- else:
- return "node: %s" % get_name()
+
+ return "node: %s" % get_name()
## Applies hover behavior on this ESCItem: if hover is enabled, applies the defined hover color modulate and/or hover texture and/or hover shader on the Sprite2D node, if any.[br]
## [br]
diff --git a/addons/escoria-core/game/core-scripts/esc_location.gd b/addons/escoria-core/game/core-scripts/esc_location.gd
index 47e9f09..8d8d93f 100644
--- a/addons/escoria-core/game/core-scripts/esc_location.gd
+++ b/addons/escoria-core/game/core-scripts/esc_location.gd
@@ -5,8 +5,8 @@
## automatically use an `ESCLocation` that is a child of the destination node.
## Commands like `turn_to`--which are not movement-based--will ignore child
## `ESCLocation`s and refer to the parent node.
-extends Marker2D
class_name ESCLocation
+extends Marker2D
## Escoria Plugin signal emitted to the `ESCRoom` when a start location is set in the `ESCLocation` node in order to check whether multiple start locations are set.[br]
@@ -56,12 +56,12 @@ var _multiple_start_locations_exist: bool = false:
## [br]
## | Name | Type | Description | Required? |[br]
## |:-----|:-----|:------------|:----------|[br]
-## |p_classname|`String`|Class name to compare against this location.|yes|[br]
+## |p_classname|`String (4.6.x), StringName (4.7.x)`|Class name to compare against this location.|yes|[br]
## [br]
## #### Returns[br]
## [br]
## Returns a `bool` value. (`bool`)
-func is_class(p_classname: String) -> bool:
+func is_class(p_classname) -> bool:
return p_classname == "ESCLocation"
@@ -77,11 +77,16 @@ func is_class(p_classname: String) -> bool:
func _ready():
if not Engine.is_editor_hint():
if not self.global_id.is_empty():
+ var force_registration = false
+ if escoria.save_manager.is_loading_game:
+ force_registration = true
escoria.object_manager.register_object(
ESCObject.new(
self.global_id,
self
- )
+ ),
+ null,
+ force_registration
)
## Escoria editor plugin: on tree exit (ie. this node was removed), notify ESCRoom to update the list of start locations.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc_player.gd b/addons/escoria-core/game/core-scripts/esc_player.gd
index 2ad2f15..5e7779d 100644
--- a/addons/escoria-core/game/core-scripts/esc_player.gd
+++ b/addons/escoria-core/game/core-scripts/esc_player.gd
@@ -1,8 +1,8 @@
@tool
@icon("res://addons/escoria-core/design/esc_player.svg")
## An Escoria playable character.
-extends ESCItem
class_name ESCPlayer
+extends ESCItem
## Whether the player can be hovered over and actions performed on like an item.
diff --git a/addons/escoria-core/game/core-scripts/esc_resource_cache.gd b/addons/escoria-core/game/core-scripts/esc_resource_cache.gd
index b096cca..ffc5ff1 100644
--- a/addons/escoria-core/game/core-scripts/esc_resource_cache.gd
+++ b/addons/escoria-core/game/core-scripts/esc_resource_cache.gd
@@ -1,6 +1,6 @@
## A cache for resources
-extends Node
class_name ESCResourceCache
+extends Node
signal resource_loading_progress(path, progress)
@@ -23,7 +23,7 @@ func queue_resource(path: String, p_in_front: bool = false, p_permanent: bool =
if path in pending:
return
- elif ResourceLoader.has_cached(path):
+ if ResourceLoader.has_cached(path):
var res = ResourceLoader.load(path)
pending[path] = ESCResourceDescriptor.new(res, p_permanent)
else:
@@ -102,20 +102,19 @@ func get_resource(path):
return res
- else:
- var res = pending[path].res
- if !pending[path].permanent:
- pending.erase(path)
+ var res = pending[path].res
+ if !pending[path].permanent:
+ pending.erase(path)
- return res
- else:
- # We can't use ESCProjectSettingsManager here since this method
- # can be called from escoria._init()
- if not ProjectSettings.get_setting("escoria/platform/skip_cache"):
- var res = ResourceLoader.load(path)
- pending[path] = ESCResourceDescriptor.new(res, true)
- return res
- return ResourceLoader.load(path)
+ return res
+
+ # We can't use ESCProjectSettingsManager here since this method
+ # can be called from escoria._init()
+ if not ProjectSettings.get_setting("escoria/platform/skip_cache"):
+ var res = ResourceLoader.load(path)
+ pending[path] = ESCResourceDescriptor.new(res, true)
+ return res
+ return ResourceLoader.load(path)
func print_progress(p_path, p_progress):
diff --git a/addons/escoria-core/game/core-scripts/esc_room.gd b/addons/escoria-core/game/core-scripts/esc_room.gd
index d86780d..a69a7c4 100644
--- a/addons/escoria-core/game/core-scripts/esc_room.gd
+++ b/addons/escoria-core/game/core-scripts/esc_room.gd
@@ -1,8 +1,8 @@
@tool
@icon("res://addons/escoria-core/design/esc_room.svg")
## A room in an Escoria based game.
-extends Node2D
class_name ESCRoom
+extends Node2D
## Debugging displays for a room.
@@ -168,8 +168,8 @@ func _connect_location_nodes() -> void:
func _connect_location_nodes_in_tree(node: Node) -> void:
for n in node.get_children():
if n is ESCLocation:
- if not n.is_connected("editor_is_start_location_set",_validate_start_locations):
- n.connect("editor_is_start_location_set", _validate_start_locations)
+ if not n.editor_is_start_location_set.is_connected(_validate_start_locations):
+ n.editor_is_start_location_set.connect(_validate_start_locations)
if n.get_child_count() > 0:
_connect_location_nodes_in_tree(n)
diff --git a/addons/escoria-core/game/core-scripts/esc_terrain.gd b/addons/escoria-core/game/core-scripts/esc_terrain.gd
index a9279f8..d899670 100644
--- a/addons/escoria-core/game/core-scripts/esc_terrain.gd
+++ b/addons/escoria-core/game/core-scripts/esc_terrain.gd
@@ -1,12 +1,8 @@
@tool
@icon("res://addons/escoria-core/design/esc_terrain.svg")
## A walkable Terrain for Escoria rooms.
-extends Node2D
class_name ESCTerrain
-
-
-## Logger class reference
-const EscLogger = preload("res://addons/escoria-core/tools/logging/esc_logger.gd")
+extends Node2D
## Visualize scales or lightmap for debugging purposes if the editor
@@ -17,6 +13,9 @@ enum DebugMode {
}
+## Logger class reference
+const EscLogger = preload("res://addons/escoria-core/tools/logging/esc_logger.gd")
+
@export_group("Scales")
## Scaling texture. If empty, items scale will always be 1.0.
@@ -169,9 +168,8 @@ func _check_multiple_enabled_navpolys(node: Node = null, is_exiting: bool = fals
"at the same time."
)
return
- else:
- navigation_enabled_found = true
- current_active_navigation_instance = n
+ navigation_enabled_found = true
+ current_active_navigation_instance = n
## Return the Color of the lightmap pixel for the specified position.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc_tooltip.gd b/addons/escoria-core/game/core-scripts/esc_tooltip.gd
index 937856a..52ddc1e 100644
--- a/addons/escoria-core/game/core-scripts/esc_tooltip.gd
+++ b/addons/escoria-core/game/core-scripts/esc_tooltip.gd
@@ -1,7 +1,7 @@
@tool
## Dynamically controlled tooltip
-extends RichTextLabel
class_name ESCTooltip
+extends RichTextLabel
## Maximum width of the label
@@ -162,7 +162,6 @@ func update_tooltip_text():
"""
Overriden method. Should not be called directly.
"""
- pass
## Update the tooltip size according to the text.[br]
diff --git a/addons/escoria-core/game/core-scripts/esc_walk_context.gd b/addons/escoria-core/game/core-scripts/esc_walk_context.gd
index c0b7c86..e0aa5a6 100644
--- a/addons/escoria-core/game/core-scripts/esc_walk_context.gd
+++ b/addons/escoria-core/game/core-scripts/esc_walk_context.gd
@@ -1,7 +1,7 @@
## The walk context describes the target of a walk command and if that command
## should be executed fast.
-extends RefCounted
class_name ESCWalkContext
+extends RefCounted
## Target object that the walk command tries to reach.
diff --git a/addons/escoria-core/game/core-scripts/migrations/esc_migration.gd b/addons/escoria-core/game/core-scripts/migrations/esc_migration.gd
index 1592f27..7aecd73 100644
--- a/addons/escoria-core/game/core-scripts/migrations/esc_migration.gd
+++ b/addons/escoria-core/game/core-scripts/migrations/esc_migration.gd
@@ -1,5 +1,5 @@
-extends RefCounted
class_name ESCMigration
+extends RefCounted
## Base class for all migration version scripts. Extending scripts should be
## named like the version they migrate the savegame to. (e.g. 1.0.0.gd, 1.0.1.gd)
diff --git a/addons/escoria-core/game/core-scripts/migrations/esc_migration_manager.gd b/addons/escoria-core/game/core-scripts/migrations/esc_migration_manager.gd
index 8db1a0d..53a833a 100644
--- a/addons/escoria-core/game/core-scripts/migrations/esc_migration_manager.gd
+++ b/addons/escoria-core/game/core-scripts/migrations/esc_migration_manager.gd
@@ -1,7 +1,7 @@
## Class that handles migrations between different game or escoria versions
## @MANAGER
-extends RefCounted
class_name ESCMigrationManager
+extends RefCounted
## Regular expression that matches a simple semver version string
@@ -160,18 +160,22 @@ func _version_between(version: String, from: String, to: String) -> bool:
if from_info.get_string("major") < version_info.get_string("major") and \
version_info.get_string("major") < to_info.get_string("major"):
return true
- elif from_info.get_string("major") == version_info.get_string("major") and \
+
+ if from_info.get_string("major") == version_info.get_string("major") and \
from_info.get_string("minor") < version_info.get_string("minor"):
return true
- elif from_info.get_string("major") == version_info.get_string("major") and \
+
+ if from_info.get_string("major") == version_info.get_string("major") and \
from_info.get_string("minor") == \
version_info.get_string("minor") and \
from_info.get_string("patch") < version_info.get_string("patch"):
return true
- elif to_info.get_string("major") == version_info.get_string("major") and \
+
+ if to_info.get_string("major") == version_info.get_string("major") and \
to_info.get_string("minor") > version_info.get_string("minor"):
return true
- elif to_info.get_string("major") == version_info.get_string("major") and \
+
+ if to_info.get_string("major") == version_info.get_string("major") and \
to_info.get_string("minor") == version_info.get_string("minor") and\
to_info.get_string("patch") > version_info.get_string("patch"):
return true
@@ -197,10 +201,12 @@ func _compare_version(version_a: String, version_b: String) -> bool:
if a_info.get_string("major") < b_info.get_string("major"):
return true
- elif a_info.get_string("major") == b_info.get_string("major") and \
+
+ if a_info.get_string("major") == b_info.get_string("major") and \
a_info.get_string("minor") < b_info.get_string("minor"):
return true
- elif a_info.get_string("major") == b_info.get_string("major") and \
+
+ if a_info.get_string("major") == b_info.get_string("major") and \
a_info.get_string("minor") == b_info.get_string("minor") and \
a_info.get_string("patch") < b_info.get_string("patch"):
return true
diff --git a/addons/escoria-core/game/core-scripts/pre-4.7/.gdignore b/addons/escoria-core/game/core-scripts/pre-4.7/.gdignore
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/game/core-scripts/pre-4.7/.gdignore
diff --git a/addons/escoria-core/game/core-scripts/pre-4.7/esc_item.4.6.gd b/addons/escoria-core/game/core-scripts/pre-4.7/esc_item.4.6.gd
new file mode 100644
index 0000000..678fad0
--- /dev/null
+++ b/addons/escoria-core/game/core-scripts/pre-4.7/esc_item.4.6.gd
@@ -0,0 +1,1434 @@
+# 4.6
+@tool
+@icon("res://addons/escoria-core/design/esc_item.svg")
+## An ``ESCItem`` defines a (usually interactive) item in the game.
+##
+## When interacting with an ``ESCItem``, the game character will automatically
+## walk to an ``ESCLocation`` that is created as a child of an ``ESCItem``.[br]
+##[br]
+## By selecting the "Is Exit" checkbox when you create an ``ESCItem``
+## node, Escoria will look for an ``:exit_scene`` event in the attached script file.
+## Any commands you place in the ``:exit_scene`` event will be run when the player
+## chooses to "use" the exit - for example, saying a goodbye, or running a
+## cutscene. Place a ``change_scene`` command inside this event to move the
+## character to the next room.
+class_name ESCItem
+extends Area2D
+
+
+## Emitted when the mouse has entered this item[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |item|`Variant`|Item that emitted the signal.|yes|[br]
+## [br]
+signal mouse_entered_item(item)
+
+## Emitted when the mouse has exited this item[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |item|`Variant`|Item that emitted the signal.|yes|[br]
+## [br]
+signal mouse_exited_item(item)
+
+## Emitted when the item was left clicked[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |global_id|`Variant`|ID of this item|yes|[br]
+## [br]
+signal mouse_left_clicked_item(global_id)
+
+## Emitted when the item was double clicked[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |global_id|`Variant`|ID of this item|yes|[br]
+## [br]
+signal mouse_double_left_clicked_item(global_id)
+
+## Emitted when the item was right clicked[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |global_id|`Variant`|ID of this item|yes|[br]
+## [br]
+signal mouse_right_clicked_item(global_id)
+
+## Emitted when the item walked to a destination[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |walk_context|`Variant`|The walk context of the command|yes|[br]
+## [br]
+signal arrived(walk_context)
+
+## Emitted when the item started talking.
+## [br]
+signal started_talking
+
+## Emitted when the item stopped talking.
+## [br]
+signal stopped_talking
+
+## Emitted when the item started walking.
+## [br]
+signal started_walking
+
+## Emitted when the item stopped walking.
+## [br]
+signal stopped_walking
+
+
+## List of forbidden characters in global_ids
+const FORBIDDEN_CHARACTERS: String = "['\"]"
+
+
+## The global ID of this item
+@export var global_id: String
+
+## The ASHES script for this item
+@export_file("*.esc", "*.ash") var esc_script: String
+
+## The node that references the camera position and zoom if this item is used
+## as a camera target
+@export var camera_node: NodePath
+
+@export_group("Display")
+
+## The name for the tooltip of this item.
+@export var tooltip_name: String
+
+@export_group("","")
+@export_group("Item Behavior")
+
+## Whether this item is movable. A movable item will be scaled with the terrain
+## and be moved with commands like ``teleport`` and ``turn_to``.
+@export var is_movable: bool = false
+
+## Color used for dialogs if that item talks.
+@export var dialog_color: Color = Color(1,1,1,1)
+
+## Default action to use if object is not in the inventory.
+@export var default_action: String
+
+## Actions in this list make the item act as a source object and wait for a
+## second clicked target object before running the action, typically
+## `USE <X> WITH <Y>` or `GIVE <X> TO <Y>`.
+@export var actions_requiring_target_object: PackedStringArray = []
+
+## If enabled, the ASHES script may have an :exit_scene event to manage scene changes.
+## For simple exits that do not require scripted actions, the ESCExit node may be
+## preferred.
+@export var is_exit: bool
+
+## Defines this item as acting as a trigger if enabled.[br]
+## Allows using specific events (defined in trigger_in_verb and trigger_out_verb
+## properties) in ASHES scripts.
+@export var is_trigger: bool
+
+## Event name that is activated when another item enters the area defined by the
+## trigger item. By default, "trigger_in".
+@export var trigger_in_verb: String = "trigger_in"
+
+## Event name that is activated when another item exits the area defined by the
+## trigger item. By default, "trigger_out".
+@export var trigger_out_verb: String = "trigger_out"
+
+## Defines whether the player can interact with this item. If false, the item
+## will not react to inputs and mouse hovers.
+@export var is_interactive: bool = true
+
+
+@export_subgroup("Hover Behavior")
+
+## Defines whether Escoria will manage a specific hover behavior when the item
+## is focused. All options below can be used together.[br]
+## This can also be expanded or overriden in your ESCGame implementation
+## (in methods ``element_focused()`` and ``element_unfocused()``).
+@export var hover_enabled: bool = false
+
+## If hover is enabled, applies this color modulation on the ESCItem sprite.
+@export var hover_modulate: Color = Color.WHITE
+
+## If hover is enabled, replaces this ESCItem sprite texture by this one.
+@export var hover_texture: Texture2D = null
+
+## If hover is enabled, applies this shader to the ESCItem sprite.
+@export var hover_shader: ShaderMaterial = null
+
+@export_subgroup("","")
+
+@export_group("","")
+@export_group("Player behavior on arrival")
+
+## Whether player character orients towards 'interaction_angle' as it arrives at
+## the item's interaction position.
+@export var player_orients_on_arrival: bool = true
+
+## If 'player_orients_on_arrival' is enabled, let the player character turn to
+## this angle when it arrives at the item's interaction position.
+@export var interaction_angle: int
+
+@export_group("","")
+@export_group("Inventory")
+
+## Default action to use if object is in the inventory
+@export var default_action_inventory: String
+
+## If enabled, combination must be done in the way it is written in ASHES script
+## ie. :use ON_ITEM[br]
+## If disabled, combination will be tried in the other way.
+@export var combine_is_one_way: bool = false
+
+## If enabled, then the object must have been picked up before using it.
+## Keep disabled for items in the background, such as buttons.
+@export var use_from_inventory_only: bool = false
+
+## The visual representation for this item when it's in the inventory
+@export var inventory_texture: Texture2D = null:
+ get = _get_inventory_texture
+
+## The visual representation for this item when it's in the inventory and hovered
+@export var inventory_texture_hovered: Texture2D = null:
+ get = _get_inventory_texture_hovered
+
+@export_group("","")
+@export_group("Movement")
+
+## If enabled, terrain scaling will not be applied and
+## node will remain at the scale set in the scene.
+@export var dont_apply_terrain_scaling: bool = false
+
+## Speed of this item if movable
+@export var speed: int = 300
+
+## Speed damp of this item if movable
+@export var v_speed_damp: float = 1.0
+
+@export_group("","")
+@export_group("Animations")
+
+## Animations resource for the item (walking, idling...)
+@export var animations: ESCAnimationResource:
+ set = set_animations
+
+## The node used to play animations
+@export var animation_player_node: NodePath = "":
+ set = _set_animation_player_node
+
+@export_group("","")
+@export_group("Custom actions")
+
+## Custom data dictionary to ease customization and custom command creation.
+## Avoid name collision using proper key names.
+@export var custom_data: Dictionary = {}:
+ get = get_custom_data,
+ set = set_custom_data
+
+
+@export_group("","")
+
+## Reference to the animation node (null if none was found)
+var animation_sprite = null
+
+## Reference to the current terrain
+var terrain: ESCTerrain
+
+## Reference to this items collision shape node
+var collision: Node
+
+
+## Reference to the sprite node
+var _sprite_node: Node = null
+
+## The movable subnode
+var _movable: ESCMovable = null
+
+## The identified animation player
+var _animation_player: ESCAnimationPlayer = null
+
+## Original sprite color modulation restored when hover ends.
+var _previous_color_modulate: Color
+
+## Original sprite texture restored when hover ends.
+var _previous_texture: Texture2D = null
+
+## Whether to force regsitration with the object manager. Defaults to false.
+var _force_registration: bool = false
+
+## Warnings for scene.
+var _scene_warnings: PackedStringArray = []
+
+
+# Add the movable node, connect signals, detect child nodes
+# and register this item
+func _ready():
+ self.process_mode = Node.PROCESS_MODE_PAUSABLE
+
+ _detect_children()
+
+ # We add ourselves to this group so we can easily get a reference to all
+ # items in a scene tree.
+ if not Engine.is_editor_hint():
+ add_to_group(escoria.GROUP_ITEM_CAN_COLLIDE)
+ if is_trigger:
+ add_to_group(escoria.GROUP_ITEM_TRIGGERS)
+
+ validate_animations(animations)
+ validate_exported_parameters()
+
+ if not input_event.is_connected(_on_input_event):
+ input_event.connect(_on_input_event)
+ if not mouse_exited.is_connected(_on_mouse_exited):
+ mouse_exited.connect(_on_mouse_exited)
+
+ # Register and connect all elements to Escoria backoffice.
+ if not Engine.is_editor_hint():
+
+ if is_movable:
+ _movable = ESCMovable.new()
+ add_child(_movable)
+
+ if not escoria.event_manager.event_finished.is_connected(_update_terrain):
+ escoria.event_manager.event_finished.connect(_update_terrain)
+
+ _force_registration = true if escoria.save_manager.is_loading_game else false
+ escoria.object_manager.register_object(
+ ESCObject.new(
+ global_id,
+ self
+ ),
+ null,
+ _force_registration
+ )
+
+ terrain = escoria.room_terrain if is_instance_valid(escoria.room_terrain) else null
+
+ if escoria.object_manager.get_object(global_id).state == ESCObject.STATE_DEFAULT \
+ and get_animation_player() != null:
+ escoria.object_manager.get_object(global_id) \
+ .set_state(get_animation_player().get_animation())
+ if is_movable:
+ escoria.object_manager.get_object(global_id).node \
+ ._movable.last_dir = animations.get_direction_id_from_animation_name(
+ get_animation_player().get_animation()
+ )
+
+
+ if escoria.object_manager.get_object(global_id).state == ESCObject.STATE_DEFAULT \
+ and escoria.object_manager.get_object(global_id).node.get_animation_player() != null:
+ escoria.object_manager.get_object(global_id) \
+ .set_state(get_animation_player().get_animation())
+ if is_movable:
+ escoria.object_manager.get_object(global_id).node._movable.last_dir = 1
+ #animations.get_direction_id_from_animation_name(
+ #.get_animation_player().get_animation()
+ #)
+
+ if !is_trigger:
+ if not self.is_connected(
+ "mouse_entered_item",
+ escoria.inputs_manager._on_mouse_entered_item
+ ):
+ mouse_entered_item.connect(
+ escoria.inputs_manager._on_mouse_entered_item
+ )
+ if not self.is_connected(
+ "mouse_exited_item",
+ escoria.inputs_manager._on_mouse_exited_item
+ ):
+ mouse_exited_item.connect(
+ escoria.inputs_manager._on_mouse_exited_item
+ )
+ if not self.is_connected(
+ "mouse_left_clicked_item",
+ escoria.inputs_manager._on_mouse_left_clicked_item
+ ):
+ mouse_left_clicked_item.connect(
+ escoria.inputs_manager._on_mouse_left_clicked_item
+ )
+ if not self.is_connected(
+ "mouse_double_left_clicked_item",
+ escoria.inputs_manager._on_mouse_left_double_clicked_item
+ ):
+ mouse_double_left_clicked_item.connect(
+ escoria.inputs_manager._on_mouse_left_double_clicked_item
+ )
+ if not self.is_connected(
+ "mouse_right_clicked_item",
+ escoria.inputs_manager._on_mouse_right_clicked_item
+ ):
+ mouse_right_clicked_item.connect(
+ escoria.inputs_manager._on_mouse_right_clicked_item
+ )
+ else: # Item is a trigger
+ if not self.is_connected("area_entered", _element_entered):
+ area_entered.connect(_element_entered)
+ if not self.is_connected("area_exited", _element_exited):
+ area_exited.connect(_element_exited)
+ if not self.is_connected("body_entered", _element_entered):
+ body_entered.connect(_element_entered)
+ if not self.is_connected("body_exited", _element_exited):
+ body_exited.connect(_element_exited)
+
+ # If object can be in the inventory, set default_action_inventory to same as
+ # default_action, if default_action_inventory is not set
+ if use_from_inventory_only and default_action_inventory.is_empty():
+ default_action_inventory = default_action
+
+ # Perform a first terrain scaling if we have to.
+ if (not is_exit or dont_apply_terrain_scaling) and is_movable:
+ _movable.last_scale = scale
+ _movable.update_terrain()
+
+## For trigger items, enable monitoring to detect collisions.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func connect_trigger_events():
+ assert(is_trigger)
+ self.monitoring = true
+
+
+## Validates the various exported parameters so we get immediate crash.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func validate_exported_parameters() -> void:
+ var regex = RegEx.new()
+ regex.compile(FORBIDDEN_CHARACTERS)
+ var result = regex.search(global_id)
+ if result:
+ ESCSafeLogging.log_error(
+ self,
+ "Forbidden character in global_id %s (path: %s)"
+ % [global_id, get_path()]
+ )
+ if global_id.is_empty():
+ ESCSafeLogging.log_error(
+ self,
+ "global_id of item is empty (node name : %s, path: %s)"
+ % [name, get_path()]
+ )
+
+## For trigger items, disable monitoring to disable collisions detection.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func disconnect_trigger_events():
+ assert(is_trigger)
+ self.monitoring = false
+
+
+## Mouse exited happens on any item that mouse cursor exited, even those UNDER the top level of overlapping stack.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _on_mouse_exited():
+ if escoria.inputs_manager.hover_stack.has(self):
+ escoria.inputs_manager.hover_stack.erase_item(self)
+ escoria.inputs_manager.unset_hovered_node(self)
+ _apply_unhover_behavior()
+
+## Special helper class used to sort stacked items by z-index.
+class HoverStackSorter:
+ static func sort_ascending_z_index(a, b):
+ if a.z_index < b.z_index:
+ return true
+ return false
+
+
+## Manage input events on the item[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |_viewport|`Object`|the viewport node the event entered|yes|[br]
+## |event|`InputEvent`|Input event delivered to the item.|yes|[br]
+## |_shape_idx|`int`|Index of the collision shape within the item that received the event.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _on_input_event(_viewport: Object, event: InputEvent, _shape_idx: int):
+ if event is InputEventMouseMotion:
+ var physics2d_dss: PhysicsDirectSpaceState2D = get_world_2d().direct_space_state
+ var params := PhysicsPointQueryParameters2D.new()
+ params.position = get_global_mouse_position()
+ params.collision_mask = 0x7FFFFFFF
+ params.collide_with_areas = true
+ var colliding: Array = physics2d_dss.intersect_point(params, 32)
+ var colliding_nodes = []
+ for c in colliding:
+ if c.collider.get("global_id") \
+ and escoria.action_manager.is_object_actionable(c.collider.global_id):
+ colliding_nodes.push_back(c.collider)
+
+ if colliding_nodes.is_empty():
+ return
+ colliding_nodes.sort_custom(Callable(HoverStackSorter, "sort_ascending_z_index"))
+ escoria.inputs_manager.hover_stack.clear()
+ escoria.inputs_manager.hover_stack.add_items(colliding_nodes)
+ escoria.inputs_manager.set_hovered_node(colliding_nodes.back())
+
+## Manage mouse button clicks on this item by sending out signals[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |input_event|`InputEvent`|Triggered event|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _unhandled_input(input_event: InputEvent) -> void:
+ # If this is a trigger, then escoria.inputs_manager is not wired up to
+ # receive the signals this function might dispatch. In particular,
+ # calling get_tree().set_input_as_handled() unnecessarily will prevent
+ # the ESCBackground from being able to process the event.
+ # See https://github.com/godot-escoria/escoria-issues/issues/147.
+ if is_trigger:
+ return
+
+ var event = input_event
+ # Note that event could be InputEventMouseButton, InputEventJoypadButton,
+ # or something else. As such, the value of the `button_index` property
+ # must be read in the context of the type of input event.
+ if input_event is InputEventJoypadButton:
+ if not input_event.is_action_pressed(escoria.inputs_manager.ESC_UI_PRIMARY_ACTION):
+ return
+
+ # For now, rather than refactor input handling to be more generic
+ # to accommodate gamepad support, we create a synthetic mouse event
+ # based on the InputEventJoypadButton.
+ event = InputEventMouseButton.new()
+ event.button_index = MOUSE_BUTTON_LEFT
+ event.double_click = false
+ event.button_pressed = true
+ # ESCActionManager expects to read the position off of the event.
+ event.position = get_global_mouse_position()
+
+ if event is InputEventMouseButton and event.is_pressed():
+ if not escoria.current_state == escoria.GameState.DEFAULT:
+ ESCSafeLogging.log_info(
+ self,
+ "Current game state doesn't accept interactions."
+ )
+ return
+ var p = get_global_mouse_position()
+ if _is_in_shape(p) and escoria.action_manager.is_object_actionable(global_id):
+ if event.double_click and event.button_index == MOUSE_BUTTON_LEFT:
+ mouse_double_left_clicked_item.emit(self, event)
+ get_viewport().set_input_as_handled()
+ elif event.button_index == MOUSE_BUTTON_LEFT:
+ mouse_left_clicked_item.emit(self, event)
+ get_viewport().set_input_as_handled()
+ elif event.button_index == MOUSE_BUTTON_RIGHT:
+ mouse_right_clicked_item.emit(self, event)
+ get_viewport().set_input_as_handled()
+
+
+## To display warnings in the scene tree should there be any.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `String` value containing configuration warnings joined by newlines. (`String`)
+func _get_configuration_warnings():
+ validate_animations(animations)
+ return "\n".join(_scene_warnings)
+
+
+## Tests whether given point is inside a shape.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |position|`Vector2`|point coordinates to test|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `bool` value. (`bool`)
+func _is_in_shape(position: Vector2) -> bool:
+ var params := PhysicsPointQueryParameters2D.new()
+ params.position = position
+ params.collision_mask = 2147483647
+ params.collide_with_areas = true
+ var colliders = get_world_2d().direct_space_state.intersect_point(
+ params,
+ 32
+ )
+ for _owner in get_shape_owners():
+ for _shape_id in range(0, shape_owner_get_shape_count(_owner)):
+ for _collider in colliders:
+ if _collider.collider == self and _collider.shape == _shape_id:
+ return true
+ return false
+
+
+## Validates the ESCAnimationResource if it exists. Note that we pass in the ESCAnimationResource as an argument so that it can also be used to validate an ESCAnimationResource prior to being set.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |animations_resource|`ESCAnimationResource`|Animation resource to validate before using it on this item.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func validate_animations(animations_resource: ESCAnimationResource) -> void:
+ if not is_instance_valid(animations_resource):
+ return
+
+ # This initialization must always be here since this is a tool script.
+ _scene_warnings = []
+
+ if is_instance_valid(animations_resource):
+ _validate_animations_property_all_not_null(animations_resource.dir_angles, "dir_angles")
+
+ var num_dir_angles = animations_resource.dir_angles.size()
+
+ if animations_resource.directions.size() != num_dir_angles:
+ _scene_warnings.append("%s animation angles specified but %s 'directions' animation(s) given. [%s]" \
+ % [num_dir_angles, animations_resource.directions.size(), _get_identifier_as_key_value()])
+ else:
+ _validate_animations_property_all_not_null(animations_resource.directions, "directions")
+
+ if animations_resource.idles.size() != num_dir_angles:
+ _scene_warnings.append("%s animation angles specified but %s 'idles' animation(s) given. [%s]" \
+ % [num_dir_angles, animations_resource.idles.size(), _get_identifier_as_key_value()])
+ else:
+ _validate_animations_property_all_not_null(animations_resource.idles, "idles")
+
+ if animations_resource.speaks.size() != num_dir_angles:
+ _scene_warnings.append("%s animation angles specified but %s 'speaks' animation(s) given. [%s]" \
+ % [num_dir_angles, animations_resource.speaks.size(), _get_identifier_as_key_value()])
+ else:
+ _validate_animations_property_all_not_null(animations_resource.speaks, "speaks")
+
+ if Engine.is_editor_hint():
+ update_configuration_warnings()
+ elif _scene_warnings.size() > 0:
+ ESCSafeLogging.log_error(
+ self,
+ ", ".join(_scene_warnings)
+ )
+
+
+## Setter for the animations property.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |p_animations|`ESCAnimationResource`|the ESCAnimationResource to set.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func set_animations(p_animations: ESCAnimationResource) -> void:
+ if p_animations == null:
+ return
+
+ animations = p_animations
+
+ if not animations.changed.is_connected(validate_animations):
+ animations.changed.connect(validate_animations)
+
+
+## The animation player node[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns the animation player node. (`Node`)
+func get_animation_player() -> Node:
+ if _animation_player == null:
+ var player_node_path = animation_player_node
+ if player_node_path.is_empty():
+ for child in self.get_children():
+ if child is AnimatedSprite2D or \
+ child is AnimationPlayer:
+ player_node_path = child.get_path()
+ if player_node_path.is_empty():
+ ESCSafeLogging.log_debug(
+ self,
+ "Can not find animation_player or animated sprite for %s." % global_id
+ )
+ elif not has_node(player_node_path):
+ ESCSafeLogging.log_debug(
+ self,
+ "Can not find animation_player node at path %s for %s." % [player_node_path, global_id]
+ )
+ else:
+ _animation_player = ESCAnimationPlayer.new(
+ get_node(player_node_path)
+ )
+ return _animation_player
+
+
+## Return the position the player needs to walk to to interact with this item. That can either be a direct Position2D child or a collision shape[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `Vector2` value. (`Vector2`)
+func get_interact_position() -> Vector2:
+ var pos_2d_count: int = 0
+ var pos_2d_position = null
+
+ var esclocation_count = 0
+ var esclocation_position = null
+
+ var interact_count = 0
+ var interact_position = null
+
+ for c in get_children():
+ if c is Marker2D:
+ # Identify any Postion2D nodes
+ if c is ESCLocation:
+ esclocation_count += 1
+ esclocation_position = c.global_position
+ elif c is ESCInteractionLocation:
+ interact_count += 1
+ interact_position = c.global_position
+ else:
+ # This will catch all other Position2D related nodes
+ # including dialog locations and native Position2D nodes.
+ pos_2d_count += 1
+ pos_2d_position = c.global_position
+
+ if interact_position == null and \
+ esclocation_position == null and is_instance_valid(collision):
+ ESCSafeLogging.log_warn(
+ self,
+ "No ESCLocation found to walk to for object " +
+ "%s. Middle of collision shape will be used." % global_id)
+ return collision.global_position
+
+ if interact_count > 0:
+ if interact_count > 1:
+ ESCSafeLogging.log_warn(
+ self,
+ "Multiple ESCInteractionLocations found to walk to for " +
+ "object %s. Last one will be used." % global_id)
+ return interact_position
+
+ if esclocation_count > 1:
+ ESCSafeLogging.log_warn(
+ self,
+ "Multiple ESClocations found to walk to for object " +
+ "%s. Last one will be used." % global_id)
+ return esclocation_position
+
+
+## React to the mouse entering the item by emitting the respective signal[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func mouse_entered():
+ if escoria.action_manager.is_object_actionable(global_id):
+ mouse_entered_item.emit(self)
+ _apply_hover_behavior()
+
+
+
+## React to the mouse exiting the item by emitting the respective signal[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func do_mouse_exited():
+ mouse_exited_item.emit(self)
+
+
+# Another item (e.g. the player) has entered this item
+#
+# #### Parameters
+#
+# - body: Other object that has entered the item
+func _element_entered(body):
+ if body is ESCBackground or body.get_parent() is ESCBackground:
+ return
+ escoria.action_manager.do(
+ escoria.action_manager.ACTION.TRIGGER_IN,
+ [global_id, body.global_id, trigger_in_verb]
+ )
+
+
+# Another item (e.g. the player) has exited this element
+#
+# #### Parameters
+#
+# - body: Other object that has exited the item
+func _element_exited(body):
+ if body is ESCBackground or body.get_parent() is ESCBackground:
+ return
+ escoria.action_manager.do(
+ escoria.action_manager.ACTION.TRIGGER_OUT,
+ [global_id, body.global_id, trigger_out_verb]
+ )
+
+
+## Use the movable node to teleport this item to the target item[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |target|`Node`|Target node to teleport to|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func teleport(target: Node) -> void:
+ if is_movable:
+ _movable.teleport(target)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot \"teleport\". Its \"is_movable\" parameter is false." %self
+ )
+
+
+## Use the movable node to teleport this item to the target position[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |target|`Vector2`|Vector2 position to teleport to|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func teleport_to(target: Vector2) -> void:
+ if is_movable:
+ _movable.teleport_to(target)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot \"teleport_to\". Its \"is_movable\" parameter is false." %self
+ )
+
+
+## Use the movable node to make the item walk to the given position[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |pos|`Vector2`|Position to walk to|yes|[br]
+## |p_walk_context|`ESCWalkContext`|Walk context to use|no|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func walk_to(pos: Vector2, p_walk_context: ESCWalkContext = null) -> void:
+ if is_movable:
+ started_walking.emit()
+ _movable.walk_to(pos, p_walk_context)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot use \"walk_to\". Its \"is_movable\" parameter is false." %self
+ )
+
+
+## Stop the movable node immediately and remain where it is at this moment, or teleport it directly at destination position if 'to_target' is true.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |to_target|`bool`|if true, the movable node is teleport directly at its target destination|no|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func stop_walking_now(to_target: bool = false) -> void:
+ if is_movable:
+ var where: Vector2 = position
+ if to_target:
+ where = _movable.walk_destination
+ stopped_walking.emit()
+ _movable.walk_stop(where)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot use \"stop_walking_now\". Its \"is_movable\" parameter is false." %self
+ )
+
+
+## Set the moving speed[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |speed_value|`int`|Set the new speed|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func set_velocity(speed_value: int) -> void:
+ speed = speed_value
+
+
+## Checks whether this item moved[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `bool` value. (`bool`)
+func has_moved() -> bool:
+ return _movable.moved if is_movable else false
+
+
+## Checks whether this item has a sprite.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `bool` value. (`bool`)
+func has_sprite() -> bool:
+ if _sprite_node != null:
+ return true
+
+ for child in self.get_children():
+ if child is AnimatedSprite2D or child is Sprite2D:
+ return true
+ return false
+
+## Return the sprite node.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `Node` value. (`Node`)
+func get_sprite() -> Node:
+ if _sprite_node == null:
+ for child in self.get_children():
+ if child is AnimatedSprite2D or child is Sprite2D:
+ _sprite_node = child
+ if _sprite_node == null:
+ ESCSafeLogging.log_error(
+ self,
+ "No sprite node found in the scene %s." % get_path()
+ )
+ return _sprite_node
+
+
+## Set the angle[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |deg|`int`|The angle degree to set|yes|[br]
+## |wait|`float`|Wait this amount of seconds until continuing with turning around|no|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func set_angle(deg: int, wait: float = 0.0):
+ if is_movable:
+ _movable.set_angle(deg, wait)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot use \"set_angle\". Its \"is_movable\" parameter is false." % self
+ )
+
+
+## Set the direction id[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |direction_id|`int`|The direction id|yes|[br]
+## |wait|`float`|Wait this amount of seconds until continuing with turning around|no|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func set_direction(direction_id: int, wait: float = 0.0):
+ if is_movable:
+ _movable.set_direction(direction_id, wait)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot use \"set_direction\". Its \"is_movable\" parameter is false." % self
+ )
+
+
+## Turn to face another object[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |object|`Node`|Node whose position the item should face.|yes|[br]
+## |wait|`float`|Optional delay in seconds before execution continues after turning.|no|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func turn_to(object: Node, wait: float = 0.0):
+ if is_movable:
+ _movable.turn_to(object, wait)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot use \"turn_to\". Its \"is_movable\" parameter is false." % self
+ )
+
+
+## Check everything is in place to play talk animations. A warning indicating the problem is printed in console, if necessary.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `bool` value. (`bool`)
+func check_talk_possible() -> bool:
+ if is_movable and (_movable.last_dir < 0 \
+ or _movable.last_dir >= animations.speaks.size()):
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot talk. Its \"last_dir\" parameter is invalid: %s." \
+ % [self, _movable.last_dir]
+ )
+ return false
+ if not is_instance_valid(animations):
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot talk. Its \"animations\" parameter is empty." \
+ % self
+ )
+ return false
+ if animations.speaks.size() == 0:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot talk. Its \"animations.speaks\" array is empty." \
+ % self
+ )
+ return false
+ if not get_animation_player():
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot talk. Its animation player can't be found." \
+ % self
+ )
+ return false
+ return true
+
+
+## Play the talking animation[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func start_talking():
+ if not check_talk_possible():
+ return
+
+ var animation_player = get_animation_player()
+
+ if animation_player.is_playing():
+ animation_player.stop()
+
+ if is_movable and animations.speaks[_movable.last_dir].mirrored \
+ and not _movable.is_mirrored:
+ _sprite_node.scale.x *= -1
+
+ var animation_direction = _movable.last_dir if is_movable else 0
+ animation_player.play(
+ animations.speaks[animation_direction].animation
+ )
+ started_talking.emit(animation_direction)
+
+
+## Stop playing the talking animation[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func stop_talking():
+ if not check_talk_possible():
+ return
+
+ var animation_player = get_animation_player()
+
+ if animation_player.is_playing():
+ animation_player.stop()
+
+ if is_movable:
+ if animations.speaks[_movable.last_dir].mirrored \
+ and not _movable.is_mirrored:
+ # Allow this function to be called multiple times without setting
+ # the direction incorrectly
+ if _sprite_node.scale.x < 1:
+ _sprite_node.scale.x *= -1
+
+ animation_player.play(
+ animations.idles[_movable.last_dir].animation
+ )
+ else:
+ animation_player.play(
+ animations.idles[0].animation
+ )
+ stopped_talking.emit()
+
+
+## Replay the last idle animation[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func update_idle():
+ get_animation_player().play(
+ animations.idles[_movable.last_dir].animation
+ )
+
+
+## Return the camera position if a camera_position_node exists or the global position of the player[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `Node` value. (`Node`)
+func get_camera_node() -> Node:
+ if has_node(camera_node):
+ ESCSafeLogging.log_debug(
+ self,
+ "Camera3D node found - directing camera to the camera_node on %s."
+ % global_id
+ )
+ return get_node(camera_node)
+ return self
+
+
+## Detect the child nodes and set respective references.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _detect_children() -> void:
+ # Initialize collision variable.
+ for c in get_children():
+ if c is CollisionShape2D or c is CollisionPolygon2D:
+ collision = c
+
+
+## Update the terrain when an event finished[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |rc|`int`|Return code of the event that just finished|yes|[br]
+## |event_name|`String`|Event that just finished to provide to update terrain call.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _update_terrain(_rc: int, event_name: String) -> void:
+ if is_movable:
+ _movable.update_terrain(event_name)
+
+## Godot method override to provide more information about this object.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _get_property_list():
+ var properties = []
+ properties.append({
+ "name": "animations",
+ "type": TYPE_OBJECT,
+ "hint": PROPERTY_HINT_RESOURCE_TYPE,
+ "hint_string": "ESCAnimationResource"
+ })
+ return properties
+
+
+## Set the node path to the animation player[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |node_path|`NodePath`|Path to the player node|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _set_animation_player_node(node_path: NodePath):
+ if not Engine.is_editor_hint():
+ return
+
+ if node_path.is_empty():
+ animation_player_node = node_path
+ return
+
+ assert(has_node(node_path), "Node with path %s not found" % node_path)
+ assert(
+ get_node(node_path) is AnimatedSprite2D or \
+ get_node(node_path) is AnimationPlayer,
+ "Selected node has to be an AnimatedSprite2D or AnimationPlayer node"
+ )
+
+ animation_player_node = node_path
+
+
+## Either the set inventory texture or the texture of a TextureRect found as a child if it is null[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns either the set inventory texture or the texture of a TextureRect found as a child if it is null. (`Texture2D`)
+func _get_inventory_texture() -> Texture2D:
+ if inventory_texture == null:
+ for c in get_children():
+ if c is TextureRect or c is Sprite2D:
+ return c.texture
+ return null
+
+ return inventory_texture
+
+
+func _get_inventory_texture_hovered() -> Texture2D:
+ if inventory_texture_hovered == null:
+ return _get_inventory_texture()
+
+ return inventory_texture_hovered
+
+## Checks whether the given ESCAnimationResource property array has all non-null entries, and adds to the scene's warnings if not[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |property|`Array`|ESCAnimationResource property. Must be an array.|yes|[br]
+## |property_name|`String`|the name of the property being passed in.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _validate_animations_property_all_not_null(property: Array, property_name: String) -> void:
+ var has_empty_entry: bool = false
+
+ for item in property:
+ if item == null:
+ has_empty_entry = true
+ break
+
+ if has_empty_entry:
+ _scene_warnings.append("At least one entry in '%s' is empty. [%s]" % [property_name, _get_identifier_as_key_value()])
+
+
+## The global ID as a key/value pair. If none is specified, use the node name. Used to tag messages.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns the global ID as a key/value pair. If none is specified, use the node name. Used to tag messages. (`String`)
+func _get_identifier_as_key_value() -> String:
+ if self.global_id:
+ return "global_id: %s" % self.global_id
+
+ return "node: %s" % get_name()
+
+## Applies hover behavior on this ESCItem: if hover is enabled, applies the defined hover color modulate and/or hover texture and/or hover shader on the Sprite2D node, if any.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _apply_hover_behavior() -> void:
+ if not hover_enabled:
+ return
+ if has_sprite():
+ var sprite = get_sprite()
+ _previous_color_modulate = modulate
+ sprite.modulate = hover_modulate
+ if sprite is Sprite2D:
+ if hover_texture != null:
+ _previous_texture = sprite.texture
+ sprite.texture = hover_texture
+ if hover_shader != null:
+ sprite.material = hover_shader
+
+## Applies unhover behavior on this ESCItem: if hover is enabled, reverts back to normal the defined hover color modulate and/or hover texture and/or hover shader on the Sprite2D node, if any.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _apply_unhover_behavior() -> void:
+ if not hover_enabled:
+ return
+ if has_sprite():
+ var sprite = get_sprite()
+ sprite.modulate = _previous_color_modulate
+ if sprite is Sprite2D:
+ if hover_texture != null:
+ sprite.texture = _previous_texture
+ if hover_shader != null:
+ sprite.material = null
+
+
+## Whether the item is currently moving.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns true if the player is currently moving, false otherwise. (`bool`)
+func is_moving() -> bool:
+ return _movable.task != ESCMovable.MovableTask.NONE if is_movable else false
+
+## The number of defined animation directions for this ESCItem.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns the number of defined animation directions for this ESCItem. (`int`)
+func get_directions_quantity() -> int:
+ return animations.dir_angles.size()
+
+
+## A Dictionary containing custom data defined for this ESCItem.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a Dictionary containing custom data defined for this ESCItem. (`Dictionary`)
+func get_custom_data() -> Dictionary:
+ return custom_data
+
+## Sets the `custom_data` Dictionary containing custom data defined for this ESCItem.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |data|`Dictionary`|Custom data dictionary to assign to this item (an empty dictionary is used when `null`).|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func set_custom_data(data: Dictionary) -> void:
+ custom_data = data if (data != null) else {}
diff --git a/addons/escoria-core/game/core-scripts/pre-4.7/esc_item.4.7.gd b/addons/escoria-core/game/core-scripts/pre-4.7/esc_item.4.7.gd
new file mode 100644
index 0000000..604addc
--- /dev/null
+++ b/addons/escoria-core/game/core-scripts/pre-4.7/esc_item.4.7.gd
@@ -0,0 +1,1432 @@
+@tool
+@icon("res://addons/escoria-core/design/esc_item.svg")
+## An ``ESCItem`` defines a (usually interactive) item in the game.
+##
+## When interacting with an ``ESCItem``, the game character will automatically
+## walk to an ``ESCLocation`` that is created as a child of an ``ESCItem``.[br]
+##[br]
+## By selecting the "Is Exit" checkbox when you create an ``ESCItem``
+## node, Escoria will look for an ``:exit_scene`` event in the attached script file.
+## Any commands you place in the ``:exit_scene`` event will be run when the player
+## chooses to "use" the exit - for example, saying a goodbye, or running a
+## cutscene. Place a ``change_scene`` command inside this event to move the
+## character to the next room.
+class_name ESCItem
+extends Area2D
+
+
+## Emitted when the mouse has entered this item[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |item|`Variant`|Item that emitted the signal.|yes|[br]
+## [br]
+signal mouse_entered_item(item)
+
+## Emitted when the mouse has exited this item[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |item|`Variant`|Item that emitted the signal.|yes|[br]
+## [br]
+signal mouse_exited_item(item)
+
+## Emitted when the item was left clicked[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |global_id|`Variant`|ID of this item|yes|[br]
+## [br]
+signal mouse_left_clicked_item(global_id)
+
+## Emitted when the item was double clicked[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |global_id|`Variant`|ID of this item|yes|[br]
+## [br]
+signal mouse_double_left_clicked_item(global_id)
+
+## Emitted when the item was right clicked[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |global_id|`Variant`|ID of this item|yes|[br]
+## [br]
+signal mouse_right_clicked_item(global_id)
+
+## Emitted when the item walked to a destination[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |walk_context|`Variant`|The walk context of the command|yes|[br]
+## [br]
+signal arrived(walk_context)
+
+## Emitted when the item started talking.
+## [br]
+signal started_talking
+
+## Emitted when the item stopped talking.
+## [br]
+signal stopped_talking
+
+## Emitted when the item started walking.
+## [br]
+signal started_walking
+
+## Emitted when the item stopped walking.
+## [br]
+signal stopped_walking
+
+
+## List of forbidden characters in global_ids
+const FORBIDDEN_CHARACTERS: String = "['\"]"
+
+
+## The global ID of this item
+@export var global_id: String
+
+## The ASHES script for this item
+@export_file("*.esc", "*.ash") var esc_script: String
+
+## The node that references the camera position and zoom if this item is used
+## as a camera target
+@export var camera_node: NodePath
+
+@export_group("Display")
+
+## The name for the tooltip of this item.
+@export var tooltip_name: String
+
+@export_group("","")
+@export_group("Item Behavior")
+
+## Whether this item is movable. A movable item will be scaled with the terrain
+## and be moved with commands like ``teleport`` and ``turn_to``.
+@export var is_movable: bool = false
+
+## Color used for dialogs if that item talks.
+@export var dialog_color: Color = Color(1,1,1,1)
+
+## Default action to use if object is not in the inventory.
+@export var default_action: String
+
+## Actions in this list make the item act as a source object and wait for a
+## second clicked target object before running the action, typically
+## `USE <X> WITH <Y>` or `GIVE <X> TO <Y>`.
+@export var actions_requiring_target_object: PackedStringArray = []
+
+## If enabled, the ASHES script may have an :exit_scene event to manage scene changes.
+## For simple exits that do not require scripted actions, the ESCExit node may be
+## preferred.
+@export var is_exit: bool
+
+## Defines this item as acting as a trigger if enabled.[br]
+## Allows using specific events (defined in trigger_in_verb and trigger_out_verb
+## properties) in ASHES scripts.
+@export var is_trigger: bool
+
+## Event name that is activated when another item enters the area defined by the
+## trigger item. By default, "trigger_in".
+@export var trigger_in_verb: String = "trigger_in"
+
+## Event name that is activated when another item exits the area defined by the
+## trigger item. By default, "trigger_out".
+@export var trigger_out_verb: String = "trigger_out"
+
+## Defines whether the player can interact with this item. If false, the item
+## will not react to inputs and mouse hovers.
+@export var is_interactive: bool = true
+
+
+@export_subgroup("Hover Behavior")
+
+## Defines whether Escoria will manage a specific hover behavior when the item
+## is focused. All options below can be used together.[br]
+## This can also be expanded or overriden in your ESCGame implementation
+## (in methods ``element_focused()`` and ``element_unfocused()``).
+@export var hover_enabled: bool = false
+
+## If hover is enabled, applies this color modulation on the ESCItem sprite.
+@export var hover_modulate: Color = Color.WHITE
+
+## If hover is enabled, replaces this ESCItem sprite texture by this one.
+@export var hover_texture: Texture2D = null
+
+## If hover is enabled, applies this shader to the ESCItem sprite.
+@export var hover_shader: ShaderMaterial = null
+
+@export_subgroup("","")
+
+@export_group("","")
+@export_group("Player behavior on arrival")
+
+## Whether player character orients towards 'interaction_angle' as it arrives at
+## the item's interaction position.
+@export var player_orients_on_arrival: bool = true
+
+## If 'player_orients_on_arrival' is enabled, let the player character turn to
+## this angle when it arrives at the item's interaction position.
+@export var interaction_angle: int
+
+@export_group("","")
+@export_group("Inventory")
+
+## Default action to use if object is in the inventory
+@export var default_action_inventory: String
+
+## If enabled, combination must be done in the way it is written in ASHES script
+## ie. :use ON_ITEM[br]
+## If disabled, combination will be tried in the other way.
+@export var combine_is_one_way: bool = false
+
+## If enabled, then the object must have been picked up before using it.
+## Keep disabled for items in the background, such as buttons.
+@export var use_from_inventory_only: bool = false
+
+## The visual representation for this item when it's in the inventory
+@export var inventory_texture: Texture2D = null:
+ get = _get_inventory_texture
+
+## The visual representation for this item when it's in the inventory and hovered
+@export var inventory_texture_hovered: Texture2D = null:
+ get = _get_inventory_texture_hovered
+
+@export_group("","")
+@export_group("Movement")
+
+## If enabled, terrain scaling will not be applied and
+## node will remain at the scale set in the scene.
+@export var dont_apply_terrain_scaling: bool = false
+
+## Speed of this item if movable
+@export var speed: int = 300
+
+## Speed damp of this item if movable
+@export var v_speed_damp: float = 1.0
+
+@export_group("","")
+@export_group("Animations")
+
+## Animations resource for the item (walking, idling...)
+@export var animations: ESCAnimationResource:
+ set = set_animations
+
+## The node used to play animations
+@export var animation_player_node: NodePath = "":
+ set = _set_animation_player_node
+
+@export_group("","")
+@export_group("Custom actions")
+
+## Custom data dictionary to ease customization and custom command creation.
+## Avoid name collision using proper key names.
+@export var custom_data: Dictionary = {}:
+ get = get_custom_data,
+ set = set_custom_data
+
+
+@export_group("","")
+
+## Reference to the animation node (null if none was found)
+var animation_sprite = null
+
+## Reference to the current terrain
+var terrain: ESCTerrain
+
+## Reference to this items collision shape node
+var collision: Node
+
+
+## Reference to the sprite node
+var _sprite_node: Node = null
+
+## The movable subnode
+var _movable: ESCMovable = null
+
+## The identified animation player
+var _animation_player: ESCAnimationPlayer = null
+
+## Original sprite color modulation restored when hover ends.
+var _previous_color_modulate: Color
+
+## Original sprite texture restored when hover ends.
+var _previous_texture: Texture2D = null
+
+## Whether to force regsitration with the object manager. Defaults to false.
+var _force_registration: bool = false
+
+## Warnings for scene.
+var _scene_warnings: PackedStringArray = []
+
+
+# Add the movable node, connect signals, detect child nodes
+# and register this item
+func _ready():
+ self.process_mode = Node.PROCESS_MODE_PAUSABLE
+
+ _detect_children()
+
+ # We add ourselves to this group so we can easily get a reference to all
+ # items in a scene tree.
+ if not Engine.is_editor_hint():
+ add_to_group(escoria.GROUP_ITEM_CAN_COLLIDE)
+ if is_trigger:
+ add_to_group(escoria.GROUP_ITEM_TRIGGERS)
+
+ validate_animations(animations)
+ validate_exported_parameters()
+
+ if not input_event.is_connected(_on_input_event):
+ input_event.connect(_on_input_event)
+ if not mouse_exited.is_connected(_on_mouse_exited):
+ mouse_exited.connect(_on_mouse_exited)
+
+ # Register and connect all elements to Escoria backoffice.
+ if not Engine.is_editor_hint():
+
+ if is_movable:
+ _movable = ESCMovable.new()
+ add_child(_movable)
+
+ if not escoria.event_manager.event_finished.is_connected(_update_terrain):
+ escoria.event_manager.event_finished.connect(_update_terrain)
+
+ _force_registration = true if escoria.save_manager.is_loading_game else false
+ escoria.object_manager.register_object(
+ ESCObject.new(
+ global_id,
+ self
+ ),
+ null,
+ _force_registration
+ )
+
+ terrain = escoria.room_terrain if is_instance_valid(escoria.room_terrain) else null
+
+ if escoria.object_manager.get_object(global_id).state == ESCObject.STATE_DEFAULT \
+ and get_animation_player() != null:
+ escoria.object_manager.get_object(global_id) \
+ .set_state(get_animation_player().get_animation())
+ if is_movable:
+ escoria.object_manager.get_object(global_id).node \
+ ._movable.last_dir = animations.get_direction_id_from_animation_name(
+ get_animation_player().get_animation()
+ )
+
+
+ if escoria.object_manager.get_object(global_id).state == ESCObject.STATE_DEFAULT \
+ and escoria.object_manager.get_object(global_id).node.get_animation_player() != null:
+ escoria.object_manager.get_object(global_id) \
+ .set_state(get_animation_player().get_animation())
+ if is_movable:
+ escoria.object_manager.get_object(global_id).node._movable.last_dir = 1
+ #animations.get_direction_id_from_animation_name(
+ #.get_animation_player().get_animation()
+ #)
+
+ if !is_trigger:
+ if not self.is_connected(
+ "mouse_entered_item",
+ escoria.inputs_manager._on_mouse_entered_item
+ ):
+ mouse_entered_item.connect(
+ escoria.inputs_manager._on_mouse_entered_item
+ )
+ if not self.is_connected(
+ "mouse_exited_item",
+ escoria.inputs_manager._on_mouse_exited_item
+ ):
+ mouse_exited_item.connect(
+ escoria.inputs_manager._on_mouse_exited_item
+ )
+ if not self.is_connected(
+ "mouse_left_clicked_item",
+ escoria.inputs_manager._on_mouse_left_clicked_item
+ ):
+ mouse_left_clicked_item.connect(
+ escoria.inputs_manager._on_mouse_left_clicked_item
+ )
+ if not self.is_connected(
+ "mouse_double_left_clicked_item",
+ escoria.inputs_manager._on_mouse_left_double_clicked_item
+ ):
+ mouse_double_left_clicked_item.connect(
+ escoria.inputs_manager._on_mouse_left_double_clicked_item
+ )
+ if not self.is_connected(
+ "mouse_right_clicked_item",
+ escoria.inputs_manager._on_mouse_right_clicked_item
+ ):
+ mouse_right_clicked_item.connect(
+ escoria.inputs_manager._on_mouse_right_clicked_item
+ )
+ else: # Item is a trigger
+ if not self.is_connected("area_entered", _element_entered):
+ area_entered.connect(_element_entered)
+ if not self.is_connected("area_exited", _element_exited):
+ area_exited.connect(_element_exited)
+ if not self.is_connected("body_entered", _element_entered):
+ body_entered.connect(_element_entered)
+ if not self.is_connected("body_exited", _element_exited):
+ body_exited.connect(_element_exited)
+
+ # If object can be in the inventory, set default_action_inventory to same as
+ # default_action, if default_action_inventory is not set
+ if use_from_inventory_only and default_action_inventory.is_empty():
+ default_action_inventory = default_action
+
+ # Perform a first terrain scaling if we have to.
+ if (not is_exit or dont_apply_terrain_scaling) and is_movable:
+ _movable.last_scale = scale
+ _movable.update_terrain()
+
+## For trigger items, enable monitoring to detect collisions.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func connect_trigger_events():
+ assert(is_trigger)
+ self.monitoring = true
+
+
+## Validates the various exported parameters so we get immediate crash.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func validate_exported_parameters() -> void:
+ var regex = RegEx.new()
+ regex.compile(FORBIDDEN_CHARACTERS)
+ var result = regex.search(global_id)
+ if result:
+ ESCSafeLogging.log_error(
+ self,
+ "Forbidden character in global_id %s (path: %s)"
+ % [global_id, get_path()]
+ )
+ if global_id.is_empty():
+ ESCSafeLogging.log_error(
+ self,
+ "global_id of item is empty (node name : %s, path: %s)"
+ % [name, get_path()]
+ )
+
+## For trigger items, disable monitoring to disable collisions detection.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func disconnect_trigger_events():
+ assert(is_trigger)
+ self.monitoring = false
+
+
+## Mouse exited happens on any item that mouse cursor exited, even those UNDER the top level of overlapping stack.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _on_mouse_exited():
+ if escoria.inputs_manager.hover_stack.has(self):
+ escoria.inputs_manager.hover_stack.erase_item(self)
+ escoria.inputs_manager.unset_hovered_node(self)
+ _apply_unhover_behavior()
+
+## Special helper class used to sort stacked items by z-index.
+class HoverStackSorter:
+ static func sort_ascending_z_index(a, b):
+ if a.z_index < b.z_index:
+ return true
+ return false
+
+
+## Manage input events on the item[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |_viewport|`Object`|the viewport node the event entered|yes|[br]
+## |event|`InputEvent`|Input event delivered to the item.|yes|[br]
+## |_shape_idx|`int`|Index of the collision shape within the item that received the event.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _on_input_event(_viewport: Object, event: InputEvent, _shape_idx: int):
+ if event is InputEventMouseMotion:
+ var physics2d_dss: PhysicsDirectSpaceState2D = get_world_2d().direct_space_state
+ var params := PhysicsPointQueryParameters2D.new()
+ params.position = get_global_mouse_position()
+ params.collision_mask = 0x7FFFFFFF
+ params.collide_with_areas = true
+ var colliding: Array = physics2d_dss.intersect_point(params, 32)
+ var colliding_nodes = []
+ for c in colliding:
+ if c.collider.get("global_id") \
+ and escoria.action_manager.is_object_actionable(c.collider.global_id):
+ colliding_nodes.push_back(c.collider)
+
+ if colliding_nodes.is_empty():
+ return
+ colliding_nodes.sort_custom(Callable(HoverStackSorter, "sort_ascending_z_index"))
+ escoria.inputs_manager.hover_stack.clear()
+ escoria.inputs_manager.hover_stack.add_items(colliding_nodes)
+ escoria.inputs_manager.set_hovered_node(colliding_nodes.back())
+
+## Manage mouse button clicks on this item by sending out signals[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |input_event|`InputEvent`|Triggered event|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _unhandled_input(input_event: InputEvent) -> void:
+ # If this is a trigger, then escoria.inputs_manager is not wired up to
+ # receive the signals this function might dispatch. In particular,
+ # calling get_tree().set_input_as_handled() unnecessarily will prevent
+ # the ESCBackground from being able to process the event.
+ # See https://github.com/godot-escoria/escoria-issues/issues/147.
+ if is_trigger:
+ return
+
+ var event = input_event
+ # Note that event could be InputEventMouseButton, InputEventJoypadButton,
+ # or something else. As such, the value of the `button_index` property
+ # must be read in the context of the type of input event.
+ if input_event is InputEventJoypadButton:
+ if not input_event.is_action_pressed(escoria.inputs_manager.ESC_UI_PRIMARY_ACTION):
+ return
+
+ # For now, rather than refactor input handling to be more generic
+ # to accommodate gamepad support, we create a synthetic mouse event
+ # based on the InputEventJoypadButton.
+ event = InputEventMouseButton.new()
+ event.button_index = MOUSE_BUTTON_LEFT
+ event.double_click = false
+ event.button_pressed = true
+ # ESCActionManager expects to read the position off of the event.
+ event.position = get_global_mouse_position()
+
+ if event is InputEventMouseButton and event.is_pressed():
+ if not escoria.current_state == escoria.GameState.DEFAULT:
+ ESCSafeLogging.log_info(
+ self,
+ "Current game state doesn't accept interactions."
+ )
+ return
+ var p = get_global_mouse_position()
+ if _is_in_shape(p) and escoria.action_manager.is_object_actionable(global_id):
+ if event.double_click and event.button_index == MOUSE_BUTTON_LEFT:
+ mouse_double_left_clicked_item.emit(self, event)
+ get_viewport().set_input_as_handled()
+ elif event.button_index == MOUSE_BUTTON_LEFT:
+ mouse_left_clicked_item.emit(self, event)
+ get_viewport().set_input_as_handled()
+ elif event.button_index == MOUSE_BUTTON_RIGHT:
+ mouse_right_clicked_item.emit(self, event)
+ get_viewport().set_input_as_handled()
+
+
+## To display warnings in the scene tree should there be any.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `PackedStringArray` value containing configuration warnings
+func _get_configuration_warnings():
+ validate_animations(animations)
+ return _scene_warnings
+
+## Tests whether given point is inside a shape.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |position|`Vector2`|point coordinates to test|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `bool` value. (`bool`)
+func _is_in_shape(position: Vector2) -> bool:
+ var params := PhysicsPointQueryParameters2D.new()
+ params.position = position
+ params.collision_mask = 2147483647
+ params.collide_with_areas = true
+ var colliders = get_world_2d().direct_space_state.intersect_point(
+ params,
+ 32
+ )
+ for _owner in get_shape_owners():
+ for _shape_id in range(0, shape_owner_get_shape_count(_owner)):
+ for _collider in colliders:
+ if _collider.collider == self and _collider.shape == _shape_id:
+ return true
+ return false
+
+
+## Validates the ESCAnimationResource if it exists. Note that we pass in the ESCAnimationResource as an argument so that it can also be used to validate an ESCAnimationResource prior to being set.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |animations_resource|`ESCAnimationResource`|Animation resource to validate before using it on this item.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func validate_animations(animations_resource: ESCAnimationResource) -> void:
+ if not is_instance_valid(animations_resource):
+ return
+
+ # This initialization must always be here since this is a tool script.
+ _scene_warnings = []
+
+ if is_instance_valid(animations_resource):
+ _validate_animations_property_all_not_null(animations_resource.dir_angles, "dir_angles")
+
+ var num_dir_angles = animations_resource.dir_angles.size()
+
+ if animations_resource.directions.size() != num_dir_angles:
+ _scene_warnings.append("%s animation angles specified but %s 'directions' animation(s) given. [%s]" \
+ % [num_dir_angles, animations_resource.directions.size(), _get_identifier_as_key_value()])
+ else:
+ _validate_animations_property_all_not_null(animations_resource.directions, "directions")
+
+ if animations_resource.idles.size() != num_dir_angles:
+ _scene_warnings.append("%s animation angles specified but %s 'idles' animation(s) given. [%s]" \
+ % [num_dir_angles, animations_resource.idles.size(), _get_identifier_as_key_value()])
+ else:
+ _validate_animations_property_all_not_null(animations_resource.idles, "idles")
+
+ if animations_resource.speaks.size() != num_dir_angles:
+ _scene_warnings.append("%s animation angles specified but %s 'speaks' animation(s) given. [%s]" \
+ % [num_dir_angles, animations_resource.speaks.size(), _get_identifier_as_key_value()])
+ else:
+ _validate_animations_property_all_not_null(animations_resource.speaks, "speaks")
+
+ if Engine.is_editor_hint():
+ update_configuration_warnings()
+ elif _scene_warnings.size() > 0:
+ ESCSafeLogging.log_error(
+ self,
+ ", ".join(_scene_warnings)
+ )
+
+
+## Setter for the animations property.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |p_animations|`ESCAnimationResource`|the ESCAnimationResource to set.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func set_animations(p_animations: ESCAnimationResource) -> void:
+ if p_animations == null:
+ return
+
+ animations = p_animations
+
+ if not animations.changed.is_connected(validate_animations):
+ animations.changed.connect(validate_animations)
+
+
+## The animation player node[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns the animation player node. (`Node`)
+func get_animation_player() -> Node:
+ if _animation_player == null:
+ var player_node_path = animation_player_node
+ if player_node_path.is_empty():
+ for child in self.get_children():
+ if child is AnimatedSprite2D or \
+ child is AnimationPlayer:
+ player_node_path = child.get_path()
+ if player_node_path.is_empty():
+ ESCSafeLogging.log_debug(
+ self,
+ "Can not find animation_player or animated sprite for %s." % global_id
+ )
+ elif not has_node(player_node_path):
+ ESCSafeLogging.log_debug(
+ self,
+ "Can not find animation_player node at path %s for %s." % [player_node_path, global_id]
+ )
+ else:
+ _animation_player = ESCAnimationPlayer.new(
+ get_node(player_node_path)
+ )
+ return _animation_player
+
+
+## Return the position the player needs to walk to to interact with this item. That can either be a direct Position2D child or a collision shape[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `Vector2` value. (`Vector2`)
+func get_interact_position() -> Vector2:
+ var pos_2d_count: int = 0
+ var pos_2d_position = null
+
+ var esclocation_count = 0
+ var esclocation_position = null
+
+ var interact_count = 0
+ var interact_position = null
+
+ for c in get_children():
+ if c is Marker2D:
+ # Identify any Postion2D nodes
+ if c is ESCLocation:
+ esclocation_count += 1
+ esclocation_position = c.global_position
+ elif c is ESCInteractionLocation:
+ interact_count += 1
+ interact_position = c.global_position
+ else:
+ # This will catch all other Position2D related nodes
+ # including dialog locations and native Position2D nodes.
+ pos_2d_count += 1
+ pos_2d_position = c.global_position
+
+ if interact_position == null and \
+ esclocation_position == null and is_instance_valid(collision):
+ ESCSafeLogging.log_warn(
+ self,
+ "No ESCLocation found to walk to for object " +
+ "%s. Middle of collision shape will be used." % global_id)
+ return collision.global_position
+
+ if interact_count > 0:
+ if interact_count > 1:
+ ESCSafeLogging.log_warn(
+ self,
+ "Multiple ESCInteractionLocations found to walk to for " +
+ "object %s. Last one will be used." % global_id)
+ return interact_position
+
+ if esclocation_count > 1:
+ ESCSafeLogging.log_warn(
+ self,
+ "Multiple ESClocations found to walk to for object " +
+ "%s. Last one will be used." % global_id)
+ return esclocation_position
+
+
+## React to the mouse entering the item by emitting the respective signal[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func mouse_entered():
+ if escoria.action_manager.is_object_actionable(global_id):
+ mouse_entered_item.emit(self)
+ _apply_hover_behavior()
+
+
+
+## React to the mouse exiting the item by emitting the respective signal[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func do_mouse_exited():
+ mouse_exited_item.emit(self)
+
+
+# Another item (e.g. the player) has entered this item
+#
+# #### Parameters
+#
+# - body: Other object that has entered the item
+func _element_entered(body):
+ if body is ESCBackground or body.get_parent() is ESCBackground:
+ return
+ escoria.action_manager.do(
+ escoria.action_manager.ACTION.TRIGGER_IN,
+ [global_id, body.global_id, trigger_in_verb]
+ )
+
+
+# Another item (e.g. the player) has exited this element
+#
+# #### Parameters
+#
+# - body: Other object that has exited the item
+func _element_exited(body):
+ if body is ESCBackground or body.get_parent() is ESCBackground:
+ return
+ escoria.action_manager.do(
+ escoria.action_manager.ACTION.TRIGGER_OUT,
+ [global_id, body.global_id, trigger_out_verb]
+ )
+
+
+## Use the movable node to teleport this item to the target item[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |target|`Node`|Target node to teleport to|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func teleport(target: Node) -> void:
+ if is_movable:
+ _movable.teleport(target)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot \"teleport\". Its \"is_movable\" parameter is false." %self
+ )
+
+
+## Use the movable node to teleport this item to the target position[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |target|`Vector2`|Vector2 position to teleport to|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func teleport_to(target: Vector2) -> void:
+ if is_movable:
+ _movable.teleport_to(target)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot \"teleport_to\". Its \"is_movable\" parameter is false." %self
+ )
+
+
+## Use the movable node to make the item walk to the given position[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |pos|`Vector2`|Position to walk to|yes|[br]
+## |p_walk_context|`ESCWalkContext`|Walk context to use|no|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func walk_to(pos: Vector2, p_walk_context: ESCWalkContext = null) -> void:
+ if is_movable:
+ started_walking.emit()
+ _movable.walk_to(pos, p_walk_context)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot use \"walk_to\". Its \"is_movable\" parameter is false." %self
+ )
+
+
+## Stop the movable node immediately and remain where it is at this moment, or teleport it directly at destination position if 'to_target' is true.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |to_target|`bool`|if true, the movable node is teleport directly at its target destination|no|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func stop_walking_now(to_target: bool = false) -> void:
+ if is_movable:
+ var where: Vector2 = position
+ if to_target:
+ where = _movable.walk_destination
+ stopped_walking.emit()
+ _movable.walk_stop(where)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot use \"stop_walking_now\". Its \"is_movable\" parameter is false." %self
+ )
+
+
+## Set the moving speed[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |speed_value|`int`|Set the new speed|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func set_velocity(speed_value: int) -> void:
+ speed = speed_value
+
+
+## Checks whether this item moved[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `bool` value. (`bool`)
+func has_moved() -> bool:
+ return _movable.moved if is_movable else false
+
+
+## Checks whether this item has a sprite.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `bool` value. (`bool`)
+func has_sprite() -> bool:
+ if _sprite_node != null:
+ return true
+
+ for child in self.get_children():
+ if child is AnimatedSprite2D or child is Sprite2D:
+ return true
+ return false
+
+## Return the sprite node.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `Node` value. (`Node`)
+func get_sprite() -> Node:
+ if _sprite_node == null:
+ for child in self.get_children():
+ if child is AnimatedSprite2D or child is Sprite2D:
+ _sprite_node = child
+ if _sprite_node == null:
+ ESCSafeLogging.log_error(
+ self,
+ "No sprite node found in the scene %s." % get_path()
+ )
+ return _sprite_node
+
+
+## Set the angle[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |deg|`int`|The angle degree to set|yes|[br]
+## |wait|`float`|Wait this amount of seconds until continuing with turning around|no|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func set_angle(deg: int, wait: float = 0.0):
+ if is_movable:
+ _movable.set_angle(deg, wait)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot use \"set_angle\". Its \"is_movable\" parameter is false." % self
+ )
+
+
+## Set the direction id[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |direction_id|`int`|The direction id|yes|[br]
+## |wait|`float`|Wait this amount of seconds until continuing with turning around|no|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func set_direction(direction_id: int, wait: float = 0.0):
+ if is_movable:
+ _movable.set_direction(direction_id, wait)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot use \"set_direction\". Its \"is_movable\" parameter is false." % self
+ )
+
+
+## Turn to face another object[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |object|`Node`|Node whose position the item should face.|yes|[br]
+## |wait|`float`|Optional delay in seconds before execution continues after turning.|no|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func turn_to(object: Node, wait: float = 0.0):
+ if is_movable:
+ _movable.turn_to(object, wait)
+ else:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot use \"turn_to\". Its \"is_movable\" parameter is false." % self
+ )
+
+
+## Check everything is in place to play talk animations. A warning indicating the problem is printed in console, if necessary.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `bool` value. (`bool`)
+func check_talk_possible() -> bool:
+ if is_movable and (_movable.last_dir < 0 \
+ or _movable.last_dir >= animations.speaks.size()):
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot talk. Its \"last_dir\" parameter is invalid: %s." \
+ % [self, _movable.last_dir]
+ )
+ return false
+ if not is_instance_valid(animations):
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot talk. Its \"animations\" parameter is empty." \
+ % self
+ )
+ return false
+ if animations.speaks.size() == 0:
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot talk. Its \"animations.speaks\" array is empty." \
+ % self
+ )
+ return false
+ if not get_animation_player():
+ ESCSafeLogging.log_warn(
+ self,
+ "Node %s cannot talk. Its animation player can't be found." \
+ % self
+ )
+ return false
+ return true
+
+
+## Play the talking animation[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func start_talking():
+ if not check_talk_possible():
+ return
+
+ var animation_player = get_animation_player()
+
+ if animation_player.is_playing():
+ animation_player.stop()
+
+ if is_movable and animations.speaks[_movable.last_dir].mirrored \
+ and not _movable.is_mirrored:
+ _sprite_node.scale.x *= -1
+
+ var animation_direction = _movable.last_dir if is_movable else 0
+ animation_player.play(
+ animations.speaks[animation_direction].animation
+ )
+ started_talking.emit(animation_direction)
+
+
+## Stop playing the talking animation[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func stop_talking():
+ if not check_talk_possible():
+ return
+
+ var animation_player = get_animation_player()
+
+ if animation_player.is_playing():
+ animation_player.stop()
+
+ if is_movable:
+ if animations.speaks[_movable.last_dir].mirrored \
+ and not _movable.is_mirrored:
+ # Allow this function to be called multiple times without setting
+ # the direction incorrectly
+ if _sprite_node.scale.x < 1:
+ _sprite_node.scale.x *= -1
+
+ animation_player.play(
+ animations.idles[_movable.last_dir].animation
+ )
+ else:
+ animation_player.play(
+ animations.idles[0].animation
+ )
+ stopped_talking.emit()
+
+
+## Replay the last idle animation[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func update_idle():
+ get_animation_player().play(
+ animations.idles[_movable.last_dir].animation
+ )
+
+
+## Return the camera position if a camera_position_node exists or the global position of the player[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `Node` value. (`Node`)
+func get_camera_node() -> Node:
+ if has_node(camera_node):
+ ESCSafeLogging.log_debug(
+ self,
+ "Camera3D node found - directing camera to the camera_node on %s."
+ % global_id
+ )
+ return get_node(camera_node)
+ return self
+
+
+## Detect the child nodes and set respective references.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _detect_children() -> void:
+ # Initialize collision variable.
+ for c in get_children():
+ if c is CollisionShape2D or c is CollisionPolygon2D:
+ collision = c
+
+
+## Update the terrain when an event finished[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |rc|`int`|Return code of the event that just finished|yes|[br]
+## |event_name|`String`|Event that just finished to provide to update terrain call.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _update_terrain(_rc: int, event_name: String) -> void:
+ if is_movable:
+ _movable.update_terrain(event_name)
+
+## Godot method override to provide more information about this object.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _get_property_list():
+ var properties = []
+ properties.append({
+ "name": "animations",
+ "type": TYPE_OBJECT,
+ "hint": PROPERTY_HINT_RESOURCE_TYPE,
+ "hint_string": "ESCAnimationResource"
+ })
+ return properties
+
+
+## Set the node path to the animation player[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |node_path|`NodePath`|Path to the player node|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _set_animation_player_node(node_path: NodePath):
+ if not Engine.is_editor_hint():
+ return
+
+ if node_path.is_empty():
+ animation_player_node = node_path
+ return
+
+ assert(has_node(node_path), "Node with path %s not found" % node_path)
+ assert(
+ get_node(node_path) is AnimatedSprite2D or \
+ get_node(node_path) is AnimationPlayer,
+ "Selected node has to be an AnimatedSprite2D or AnimationPlayer node"
+ )
+
+ animation_player_node = node_path
+
+
+## Either the set inventory texture or the texture of a TextureRect found as a child if it is null[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns either the set inventory texture or the texture of a TextureRect found as a child if it is null. (`Texture2D`)
+func _get_inventory_texture() -> Texture2D:
+ if inventory_texture == null:
+ for c in get_children():
+ if c is TextureRect or c is Sprite2D:
+ return c.texture
+ return null
+
+ return inventory_texture
+
+
+func _get_inventory_texture_hovered() -> Texture2D:
+ if inventory_texture_hovered == null:
+ return _get_inventory_texture()
+
+ return inventory_texture_hovered
+
+## Checks whether the given ESCAnimationResource property array has all non-null entries, and adds to the scene's warnings if not[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |property|`Array`|ESCAnimationResource property. Must be an array.|yes|[br]
+## |property_name|`String`|the name of the property being passed in.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _validate_animations_property_all_not_null(property: Array, property_name: String) -> void:
+ var has_empty_entry: bool = false
+
+ for item in property:
+ if item == null:
+ has_empty_entry = true
+ break
+
+ if has_empty_entry:
+ _scene_warnings.append("At least one entry in '%s' is empty. [%s]" % [property_name, _get_identifier_as_key_value()])
+
+
+## The global ID as a key/value pair. If none is specified, use the node name. Used to tag messages.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns the global ID as a key/value pair. If none is specified, use the node name. Used to tag messages. (`String`)
+func _get_identifier_as_key_value() -> String:
+ if self.global_id:
+ return "global_id: %s" % self.global_id
+
+ return "node: %s" % get_name()
+
+## Applies hover behavior on this ESCItem: if hover is enabled, applies the defined hover color modulate and/or hover texture and/or hover shader on the Sprite2D node, if any.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _apply_hover_behavior() -> void:
+ if not hover_enabled:
+ return
+ if has_sprite():
+ var sprite = get_sprite()
+ _previous_color_modulate = modulate
+ sprite.modulate = hover_modulate
+ if sprite is Sprite2D:
+ if hover_texture != null:
+ _previous_texture = sprite.texture
+ sprite.texture = hover_texture
+ if hover_shader != null:
+ sprite.material = hover_shader
+
+## Applies unhover behavior on this ESCItem: if hover is enabled, reverts back to normal the defined hover color modulate and/or hover texture and/or hover shader on the Sprite2D node, if any.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func _apply_unhover_behavior() -> void:
+ if not hover_enabled:
+ return
+ if has_sprite():
+ var sprite = get_sprite()
+ sprite.modulate = _previous_color_modulate
+ if sprite is Sprite2D:
+ if hover_texture != null:
+ sprite.texture = _previous_texture
+ if hover_shader != null:
+ sprite.material = null
+
+
+## Whether the item is currently moving.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns true if the player is currently moving, false otherwise. (`bool`)
+func is_moving() -> bool:
+ return _movable.task != ESCMovable.MovableTask.NONE if is_movable else false
+
+## The number of defined animation directions for this ESCItem.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns the number of defined animation directions for this ESCItem. (`int`)
+func get_directions_quantity() -> int:
+ return animations.dir_angles.size()
+
+
+## A Dictionary containing custom data defined for this ESCItem.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a Dictionary containing custom data defined for this ESCItem. (`Dictionary`)
+func get_custom_data() -> Dictionary:
+ return custom_data
+
+## Sets the `custom_data` Dictionary containing custom data defined for this ESCItem.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## | Name | Type | Description | Required? |[br]
+## |:-----|:-----|:------------|:----------|[br]
+## |data|`Dictionary`|Custom data dictionary to assign to this item (an empty dictionary is used when `null`).|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func set_custom_data(data: Dictionary) -> void:
+ custom_data = data if (data != null) else {}
diff --git a/addons/escoria-core/game/core-scripts/pre-4.7/last_version_used.json b/addons/escoria-core/game/core-scripts/pre-4.7/last_version_used.json
new file mode 100644
index 0000000..5520f01
--- /dev/null
+++ b/addons/escoria-core/game/core-scripts/pre-4.7/last_version_used.json
@@ -0,0 +1 @@
+{"last_engine_version":"4.7.1-stable (voidlinux)","last_engine_version_hex":263937,"migration_done":true} \ No newline at end of file
diff --git a/addons/escoria-core/game/core-scripts/resources/esc_animationname.gd b/addons/escoria-core/game/core-scripts/resources/esc_animationname.gd
index 482695f..a679286 100644
--- a/addons/escoria-core/game/core-scripts/resources/esc_animationname.gd
+++ b/addons/escoria-core/game/core-scripts/resources/esc_animationname.gd
@@ -1,7 +1,7 @@
@tool
## Class defining an animation to use for an angle.
-extends Resource
class_name ESCAnimationName
+extends Resource
## Name of the animation
@export var animation: String
diff --git a/addons/escoria-core/game/core-scripts/resources/esc_animationresource.gd b/addons/escoria-core/game/core-scripts/resources/esc_animationresource.gd
index 337013e..26982b0 100644
--- a/addons/escoria-core/game/core-scripts/resources/esc_animationresource.gd
+++ b/addons/escoria-core/game/core-scripts/resources/esc_animationresource.gd
@@ -1,8 +1,8 @@
@tool
## Resource containing all defined animations and angles for character
## movement.[br]
-extends Resource
class_name ESCAnimationResource
+extends Resource
## Array containing the different angles available for animations.[br]
## Each angle is defined by an array [start_angle, angle_size].
diff --git a/addons/escoria-core/game/core-scripts/resources/esc_directionangle.gd b/addons/escoria-core/game/core-scripts/resources/esc_directionangle.gd
index 0050dc4..deab0f3 100644
--- a/addons/escoria-core/game/core-scripts/resources/esc_directionangle.gd
+++ b/addons/escoria-core/game/core-scripts/resources/esc_directionangle.gd
@@ -1,7 +1,7 @@
@tool
## Class defining an angle, with a start angle (between 0 and 360) and a size.
-extends Resource
class_name ESCDirectionAngle
+extends Resource
## Start angle of the directional angle.
@export var angle_start: int
diff --git a/addons/escoria-core/game/core-scripts/resources/esc_resource_descriptor.gd b/addons/escoria-core/game/core-scripts/resources/esc_resource_descriptor.gd
index 72e2cce..2da6e92 100644
--- a/addons/escoria-core/game/core-scripts/resources/esc_resource_descriptor.gd
+++ b/addons/escoria-core/game/core-scripts/resources/esc_resource_descriptor.gd
@@ -1,6 +1,6 @@
## Describes a resource for use in the resource cache.
-extends Resource
class_name ESCResourceDescriptor
+extends Resource
## The resource being described.
var res
diff --git a/addons/escoria-core/game/core-scripts/save_data/esc_save_manager.gd b/addons/escoria-core/game/core-scripts/save_data/esc_save_manager.gd
index ec78fe4..9b4ba24 100644
--- a/addons/escoria-core/game/core-scripts/save_data/esc_save_manager.gd
+++ b/addons/escoria-core/game/core-scripts/save_data/esc_save_manager.gd
@@ -48,15 +48,18 @@ var _add_inventory: InventoryAddCommand
var _transition: TransitionCommand
var _hide_menu: HideMenuCommand
var _camera_set_target: CameraSetTargetCommand
+var _camera_set_limits: CameraSetLimitsCommand
var _change_scene: ChangeSceneCommand
var _enable_terrain: EnableTerrainCommand
var _set_active: SetActiveCommand
var _set_active_if_exists: SetActiveIfExistsCommand
+var _set_animations: SetAnimationsCommand
var _set_interactive: SetInteractiveCommand
var _set_item_custom_data: SetItemCustomDataCommand
var _teleport_pos: TeleportPosCommand
var _set_direction: SetDirectionCommand
var _set_global: SetGlobalCommand
+var _set_speed: SetSpeedCommand
var _set_state: SetStateCommand
var _stop_snd: StopSndCommand
var _play_snd: PlaySndCommand
@@ -81,6 +84,7 @@ func _init():
_transition = TransitionCommand.new()
_hide_menu = HideMenuCommand.new()
_camera_set_target = CameraSetTargetCommand.new()
+ _camera_set_limits = CameraSetLimitsCommand.new()
_change_scene = ChangeSceneCommand.new()
_enable_terrain = EnableTerrainCommand.new()
_set_active = SetActiveCommand.new()
@@ -88,8 +92,10 @@ func _init():
_set_interactive = SetInteractiveCommand.new()
_set_item_custom_data = SetItemCustomDataCommand.new()
_teleport_pos = TeleportPosCommand.new()
+ _set_animations = SetAnimationsCommand.new()
_set_direction = SetDirectionCommand.new()
_set_global = SetGlobalCommand.new()
+ _set_speed = SetSpeedCommand.new()
_set_state = SetStateCommand.new()
_stop_snd = StopSndCommand.new()
_play_snd = PlaySndCommand.new()
@@ -297,6 +303,9 @@ func load_game(id: int):
)
return
+ # Stop all musics and sounds
+ escoria.main.stop_all_sounds()
+
# Disconnect all trigger areas in the current room so that they don't
# trigger after room is loaded (eg: when player was in a trigger area,
# trigger_out won't fire after loading the game)
@@ -318,14 +327,12 @@ func load_game(id: int):
"Loading savegame %s" % str(id)
)
is_loading_game = true
- escoria.current_state = escoria.GAME_STATE.LOADING
+ escoria.current_state = escoria.GameState.LOADING
var save_game: ESCSaveGame = ResourceLoader.load(save_file_path)
escoria.settings_manager.load_settings_from_dict(save_game.settings)
- escoria.settings_manager.load_settings_from_dict(save_game.settings)
-
var plugin_config = ConfigFile.new()
plugin_config.load("res://addons/escoria-core/plugin.cfg")
var escoria_version = plugin_config.get_value("plugin", "version")
@@ -385,11 +392,22 @@ func load_game(id: int):
# Set save manager "is_loading_game" var to false since we're now done
# Hide main and pause menus
- escoria.game_scene.hide_main_menu()
+ escoria.game_scene.hide_main_menu() # THIS SETS escoria.current_state = DEFAULT !!
escoria.game_scene.unpause_game()
+ escoria.current_state = escoria.GameState.LOADING
+
escoria.room_manager.change_scene_to_file(save_game.main.current_scene_filename, false)
+ # Pop sound and music data from the savegame
+ var sound_music_saved_data: Dictionary = {}
+ for object_id in save_game.objects:
+ if object_id in ESCObjectManager.RESERVED_OBJECTS:
+ sound_music_saved_data[object_id] = save_game.objects[object_id]
+ for object_id in sound_music_saved_data:
+ save_game.objects.erase(object_id)
+
+ # Load the object
_load_savegame_objects(save_game.objects)
escoria.globals_manager.clear()
@@ -397,7 +415,10 @@ func load_game(id: int):
_load_savegame_inventory(save_game.inventory)
_load_savegame_terrain_navpolys(save_game.terrain_navpolys)
_load_savegame_events(save_game.events)
- _transition.run(["", "in", 1.0])
+ await _transition.run(["", "in", 1.0])
+
+ # Music and sound resume
+ _load_savegame_objects(sound_music_saved_data)
escoria.set_game_paused(false)
@@ -405,7 +426,7 @@ func load_game(id: int):
escoria.action_manager.clear_current_tool()
escoria.inputs_manager.input_mode = escoria.inputs_manager.INPUT_ALL
is_loading_game = false
- escoria.current_state = escoria.GAME_STATE.DEFAULT
+ escoria.current_state = escoria.GameState.DEFAULT
emit_signal("game_finished_loading")
@@ -425,12 +446,19 @@ func load_game(id: int):
func _load_savegame_objects(savegame_objects: Dictionary):
for object_id in savegame_objects:
var saved_object_data = savegame_objects[object_id]
- if object_id in ESCObjectManager.RESERVED_OBJECTS: # Sound players only atm
+ if object_id in ESCObjectManager.RESERVED_OBJECTS:
+ # Sound players only atm
+ # Sounds and music to be resumed after transition
if saved_object_data.has("state") \
and saved_object_data["state"] in ["off", "default"]:
_stop_snd.run([object_id])
else:
- _play_snd.run([saved_object_data["state"], object_id, saved_object_data["playback_position"]])
+ var playback_position: float = 0.0
+ if ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.SAVE_SOUNDS_PLAYBACK_POSITION
+ ) and saved_object_data.has("playback_position"):
+ playback_position = saved_object_data["playback_position"]
+ _play_snd.run([saved_object_data["state"], object_id, playback_position])
else:
if object_id == escoria.main.current_scene.global_id:
_load_room_objects(object_id, saved_object_data)
@@ -450,8 +478,15 @@ func _load_savegame_objects(savegame_objects: Dictionary):
func _load_room_objects(room_id: String, objects_dictionary: Dictionary):
escoria.logger.info(self, "Loading room '%s'" % room_id)
+ var camera_loaded_data = null
+ if objects_dictionary.has(escoria.object_manager.CAMERA):
+ camera_loaded_data = objects_dictionary[escoria.object_manager.CAMERA]
+ objects_dictionary.erase(escoria.object_manager.CAMERA)
for object_id in objects_dictionary:
_load_object(object_id, objects_dictionary[object_id], room_id)
+ # Then load camera
+ if camera_loaded_data != null:
+ _load_object(escoria.object_manager.CAMERA, camera_loaded_data, room_id)
escoria.logger.info(self, "Finished loading room '%s'" % room_id)
@@ -468,10 +503,17 @@ func _load_room_objects(room_id: String, objects_dictionary: Dictionary):
## #### Returns[br]
## [br]
## Returns nothing.
-func _load_object(object_id: String, object_dictionary: Dictionary, room_id: String):
+func _load_object(object_id: String, object_dictionary: Dictionary, _room_id: String):
escoria.logger.info(self, "Loading object '%s'" % object_id)
if object_id == ESCObjectManager.CAMERA:
+ if object_dictionary.has("limit_id"):
+ escoria.main.restore_camera_limit_for_room_global_id(
+ _room_id,
+ object_dictionary["limit_id"]
+ )
+ else:
+ escoria.main.clear_camera_limit_id_for_room_global_id(_room_id)
_camera_set_target.run([0, object_dictionary["target"]])
else:
# Active
@@ -482,6 +524,17 @@ func _load_object(object_id: String, object_dictionary: Dictionary, room_id: Str
if object_dictionary.has("interactive") and _set_interactive.validate([object_id, object_dictionary["interactive"]]):
_set_interactive.run([object_id, object_dictionary["interactive"]])
+ # Animations
+ if object_dictionary.has("animations_resource_path") and _set_animations.validate([object_id, object_dictionary["animations_resource_path"]]):
+ _set_animations.run([object_id, object_dictionary["animations_resource_path"]])
+ if object_dictionary.has("current_animation")\
+ and _set_state.validate([
+ object_id,
+ object_dictionary["current_animation"],
+ true
+ ]):
+ _set_state.run([object_id, object_dictionary["current_animation"], true])
+
# State
if object_dictionary.has("state") and _set_state.validate([object_id, object_dictionary["state"], true]):
_set_state.run([object_id, object_dictionary["state"], true])
@@ -494,6 +547,10 @@ func _load_object(object_id: String, object_dictionary: Dictionary, room_id: Str
object_dictionary["global_transform"].origin.y
])
+ # Speed
+ if object_dictionary.has("speed"):
+ _set_speed.run([object_id, object_dictionary["speed"]])
+
# Orientation
if object_dictionary.has("last_dir"):
_set_direction.run([object_id, int(object_dictionary["last_dir"]), 0.0])
@@ -590,7 +647,7 @@ func _load_savegame_events(savegame_events: Dictionary):
sched_event.object
)
escoria.event_manager.schedule_event(
- script.events[sched_event.event_name],
+ script.get_event_with_target(sched_event.event_name),
sched_event.timeout,
sched_event.object
)
@@ -609,7 +666,7 @@ func _load_savegame_events(savegame_events: Dictionary):
## #### Returns[br]
## [br]
## Returns nothing.
-func _ensure_directory_exists(dir: String) -> void:
+func _ensure_directory_exists(_dir: String) -> void:
if not DirAccess.dir_exists_absolute(save_folder):
var return_code = DirAccess.make_dir_absolute(save_folder)
diff --git a/addons/escoria-core/game/core-scripts/save_data/esc_savegame.gd b/addons/escoria-core/game/core-scripts/save_data/esc_savegame.gd
index b49ea0e..e8cd39d 100644
--- a/addons/escoria-core/game/core-scripts/save_data/esc_savegame.gd
+++ b/addons/escoria-core/game/core-scripts/save_data/esc_savegame.gd
@@ -1,6 +1,6 @@
## Resource used for holding savegames data.
-extends Resource
class_name ESCSaveGame
+extends Resource
## Access key for the main data last_scene_global_id.
const MAIN_LAST_SCENE_GLOBAL_ID_KEY = "last_scene_global_id"
diff --git a/addons/escoria-core/game/core-scripts/save_data/esc_savesettings.gd b/addons/escoria-core/game/core-scripts/save_data/esc_savesettings.gd
index ccc3d1e..d899601 100644
--- a/addons/escoria-core/game/core-scripts/save_data/esc_savesettings.gd
+++ b/addons/escoria-core/game/core-scripts/save_data/esc_savesettings.gd
@@ -1,8 +1,8 @@
## Resource holding game settings. Note that we call directly to ProjectSettings
## for instance variable initialization since this class is instantiated from
## escoria.gd.
-extends Resource
class_name ESCSaveSettings
+extends Resource
## Version of ESCORIA Framework.
@export var escoria_version: String
diff --git a/addons/escoria-core/game/core-scripts/save_data/esc_settings_manager.gd b/addons/escoria-core/game/core-scripts/save_data/esc_settings_manager.gd
index 18d4247..71eca78 100644
--- a/addons/escoria-core/game/core-scripts/save_data/esc_settings_manager.gd
+++ b/addons/escoria-core/game/core-scripts/save_data/esc_settings_manager.gd
@@ -293,3 +293,8 @@ func save_settings():
self,
"There was an issue writing settings %s" % save_path
)
+
+
+## Returns a custom setting by key or an optional default value. Returns null if not found and no default value is defined.
+func get_custom_setting(key: String, default_value = null):
+ return custom_settings.get(key, default_value)
diff --git a/addons/escoria-core/game/core-scripts/utils/esc_utils.gd b/addons/escoria-core/game/core-scripts/utils/esc_utils.gd
index 14e4246..e1bf12e 100644
--- a/addons/escoria-core/game/core-scripts/utils/esc_utils.gd
+++ b/addons/escoria-core/game/core-scripts/utils/esc_utils.gd
@@ -1,6 +1,6 @@
## A set of common utilities.
-extends RefCounted
class_name ESCUtils
+extends RefCounted
## Convert radians to degrees.[br]
## [br]
@@ -36,8 +36,7 @@ static func get_deg_from_rad(rad_angle: float):
static func get_re_group(re_match: RegExMatch, group: String) -> String:
if group in re_match.names:
return re_match.strings[re_match.names[group]]
- else:
- return ""
+ return ""
## Return a string value in the correct inferred type.[br]
## [br]
@@ -61,16 +60,19 @@ static func get_typed_value(value: String, type_hint = []):
if regex_float.search(value):
return float(value)
- elif regex_int.search(value):
+
+ if regex_int.search(value):
return int(value)
- elif regex_bool.search(value.to_lower()):
+
+ if regex_bool.search(value.to_lower()):
return true if value.to_lower() == "true" else false
- elif (typeof(type_hint) != TYPE_ARRAY and type_hint == TYPE_ARRAY) or \
+
+ if (typeof(type_hint) != TYPE_ARRAY and type_hint == TYPE_ARRAY) or \
(typeof(type_hint) == TYPE_ARRAY and TYPE_ARRAY in type_hint) \
and "," in value:
return value.split(",")
- else:
- return str(value)
+
+ return str(value)
## Sanitize use of whitespaces in a string. Removes double whitespaces and converts tabs into space.[br]
## [br]
diff --git a/addons/escoria-core/game/esc_autoload.gd b/addons/escoria-core/game/esc_autoload.gd
index 72ef6f8..a36b9da 100644
--- a/addons/escoria-core/game/esc_autoload.gd
+++ b/addons/escoria-core/game/esc_autoload.gd
@@ -2,6 +2,14 @@ extends Node
## This is Escoria's singleton script.
## It holds accessors to some utils, such as Escoria's logger.
+## Signal sent when Escoria starts a new game[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+signal new_game_started
+
## Signal sent when Escoria is paused[br]
## [br]
## #### Parameters[br]
@@ -24,7 +32,7 @@ signal resumed
## * DIALOG: Game is playing a dialog
## * WAIT: Game is waiting
## * LOADING: Game is currently loading
-enum GAME_STATE {
+enum GameState {
DEFAULT,
DIALOG,
WAIT,
@@ -149,8 +157,8 @@ var creating_new_game: bool = false
ProjectSettings.get_setting("display/window/size/viewport_width"),
ProjectSettings.get_setting("display/window/size/viewport_height"))
-## Current state of Escoria (GAME_STATE enum)
-@onready var current_state = GAME_STATE.DEFAULT
+## Current state of Escoria (GameState enum)
+@onready var current_state = GameState.DEFAULT
## Ready function. Instantiates the main scene if running a room directly.[br]
## [br]
@@ -196,8 +204,7 @@ func get_escoria():
# We check if we run the full game or a room scene directly
if get_tree().current_scene is ESCMain:
return get_node("/root/main_scene").escoria_node
- else:
- return get_node("main_scene").escoria_node
+ return get_node("main_scene").escoria_node
## Pauses or unpause the game[br]
diff --git a/addons/escoria-core/game/esc_dependency_injector.gd b/addons/escoria-core/game/esc_dependency_injector.gd
index 570ec3d..a80be88 100644
--- a/addons/escoria-core/game/esc_dependency_injector.gd
+++ b/addons/escoria-core/game/esc_dependency_injector.gd
@@ -25,5 +25,3 @@ class_name ESCDependencyInjector
## Returns nothing.
func esc_inventory_item(p_item: ESCItem):
return ESCInventoryItem.new(p_item)
-
-# TODO Add methods for relevant classes. \ No newline at end of file
diff --git a/addons/escoria-core/game/esc_inputs_manager.gd b/addons/escoria-core/game/esc_inputs_manager.gd
index 762da5e..b2d8b5b 100644
--- a/addons/escoria-core/game/esc_inputs_manager.gd
+++ b/addons/escoria-core/game/esc_inputs_manager.gd
@@ -2,8 +2,11 @@
##
## Catches, handles and distributes input events for the game.
## @MANAGER
-extends Resource
class_name ESCInputsManager
+extends Resource
+
+## Emitted when input mode changes.
+signal input_mode_changed(new_mode: int)
## Valid input flags[br]
@@ -27,7 +30,12 @@ const ESC_UI_PRIMARY_ACTION = "esc_ui_primary_action"
## The current input mode
-var input_mode = INPUT_ALL
+var input_mode = INPUT_ALL:
+ get:
+ return input_mode
+ set(new_mode):
+ input_mode = new_mode
+ input_mode_changed.emit(new_mode)
## A LIFO stack of hovered items
var hover_stack: HoverStack
@@ -43,7 +51,7 @@ var hotspot_focused: String = ""
## #### Parameters[br]
##[br]
## - event: The event to process[br]
-## - is_default_state: Whether the current state is escoria.GAME_STATE.DEFAULT[br]
+## - is_default_state: Whether the current state is escoria.GameState.DEFAULT[br]
##[br]
## **Returns** Whether the function processed the event.
var custom_input_handler = null
@@ -77,7 +85,7 @@ func _init():
## #### Returns[br]
## [br]
## Returns nothing.
-func _on_event_finished(return_code: int, event_name: String):
+func _on_event_finished(_return_code: int, _event_name: String):
if _hovered_element == null:
hotspot_focused = ""
@@ -138,7 +146,7 @@ func register_background(background: ESCBackground):
## [br]
## | Name | Type | Description | Required? |[br]
## |:-----|:-----|:------------|:----------|[br]
-## |callback|`Variant`|Function reference satisfying the above contract event The event to process is_default_state Whether the current state is escoria.GAME_STATE.DEFAULT returns whether the function processed the event `callback` is responsible for calling `get_tree().set_input_as_handled()`, if appropriate.|yes|[br]
+## |callback|`Variant`|Function reference satisfying the above contract event The event to process is_default_state Whether the current state is escoria.GameState.DEFAULT returns whether the function processed the event `callback` is responsible for calling `get_tree().set_input_as_handled()`, if appropriate.|yes|[br]
## [br]
## #### Returns[br]
## [br]
@@ -154,7 +162,7 @@ func register_custom_input_handler(callback) -> void:
## | Name | Type | Description | Required? |[br]
## |:-----|:-----|:------------|:----------|[br]
## |event|`InputEvent`|The event to process|yes|[br]
-## |is_default_state|`bool`|Whether the current state is escoria.GAME_STATE.DEFAULT|yes|[br]
+## |is_default_state|`bool`|Whether the current state is escoria.GameState.DEFAULT|yes|[br]
## [br]
## #### Returns[br]
## [br]
@@ -162,8 +170,8 @@ func register_custom_input_handler(callback) -> void:
func try_custom_input_handler(event: InputEvent, is_default_state: bool) -> bool:
if custom_input_handler:
return custom_input_handler.call(event, is_default_state)
- else:
- return false
+
+ return false
## Callback called by hover stack content change.[br]
## [br]
@@ -209,9 +217,9 @@ func set_hovered_node(item: ESCItem) -> bool:
_hovered_element = item
_hovered_element.mouse_entered()
return true
+
# Else, the tested item is currently on top of hover stack, then do nothing
- else:
- return false
+ return false
## Unsets the hovered node.[br]
@@ -506,10 +514,10 @@ func on_item_non_interactive(item: ESCItem) -> void:
if hover_stack.is_empty():
return
- else:
- var new_item = hover_stack.get_top_item()
- escoria.action_manager.set_action_input_state(ESCActionManager.ACTION_INPUT_STATE.AWAITING_VERB_OR_ITEM)
- new_item.mouse_entered()
+
+ var new_item = hover_stack.get_top_item()
+ escoria.action_manager.set_action_input_state(ESCActionManager.ActionInputState.AWAITING_VERB_OR_ITEM)
+ new_item.mouse_entered()
# An Escoria item was clicked with the LMB
diff --git a/addons/escoria-core/game/esc_project_settings_manager.gd b/addons/escoria-core/game/esc_project_settings_manager.gd
index 3a2b10b..492a269 100644
--- a/addons/escoria-core/game/esc_project_settings_manager.gd
+++ b/addons/escoria-core/game/esc_project_settings_manager.gd
@@ -1,7 +1,7 @@
## Registers and allows access to Escoria-specific project settings.
## @MANAGER
-extends Resource
class_name ESCProjectSettingsManager
+extends Resource
## Root for Escoria-specific project settings.
const _ESCORIA_SETTINGS_ROOT = "escoria"
@@ -47,6 +47,9 @@ const SETTINGS_PATH = _ESCORIA_SETTINGS_ROOT + "/" + _MAIN_ROOT + "/" + "setting
const TEXT_LANG = _ESCORIA_SETTINGS_ROOT + "/" + _MAIN_ROOT + "/" + "text_lang"
## Path to the voice language setting.
const VOICE_LANG = _ESCORIA_SETTINGS_ROOT + "/" + _MAIN_ROOT + "/" + "voice_lang"
+## Save sounds playback position in savegames. If this is false, playback will resume from beginning
+## after loading.
+const SAVE_SOUNDS_PLAYBACK_POSITION = _ESCORIA_SETTINGS_ROOT + "/" + _MAIN_ROOT + "/" + "save_sounds_playback_position"
## Debug-related Escoria project settings root.
const _DEBUG_ROOT = "debug"
diff --git a/addons/escoria-core/game/escoria.gd b/addons/escoria-core/game/escoria.gd
index 9da2e9f..1fb5e91 100644
--- a/addons/escoria-core/game/escoria.gd
+++ b/addons/escoria-core/game/escoria.gd
@@ -1,6 +1,6 @@
@tool
-extends Node
class_name Escoria
+extends Node
## The main Escoria script.
@@ -173,7 +173,6 @@ func _input(event: InputEvent):
if event.is_action_pressed("ui_cancel"):
request_pause_menu.emit()
- pass
## Runs the event "event_name" from the "script" ESC script.[br]
@@ -188,19 +187,20 @@ func _input(event: InputEvent):
## [br]
## #### Returns[br]
## [br]
-## Returns Nothing. Waits for the event to finish before returning. (`Variant`)
-func run_event_from_script(script: ESCScript, event_name: String, from_statement_id: int = 0):
+## Returns ESCExecution. Waits for the event to finish before returning. (`Variant`)
+func run_event_from_script(script: ESCScript, event_name: String, _from_statement_id: int = 0):
if script == null:
escoria.logger.error(
self,
"Requested event %s on unloaded script %s." % [event_name, script] +
"Please load the ESC script using esc_compiler.load_esc_file()."
)
+ return ESCExecution.RC_WONT_QUEUE
if not _event_exists_in_script(script, event_name):
- return
+ return ESCExecution.RC_WONT_QUEUE
- escoria.event_manager.queue_event(script.events[event_name])
+ escoria.event_manager.queue_event(script.get_event_with_target(event_name))
var rc = await escoria.event_manager.event_finished
while rc[1] != event_name:
rc = await escoria.event_manager.event_finished
@@ -210,10 +210,12 @@ func run_event_from_script(script: ESCScript, event_name: String, from_statement
self,
"Start event of the start script returned unsuccessful: %d." % rc[0]
)
- return
+
+ return rc[0]
## Checks for the existence of both mandatory and optional events within a specified script.[br]
+## We ignore any target for the event, at least for now.[br]
## [br]
## #### Parameters[br]
## [br]
@@ -226,7 +228,7 @@ func run_event_from_script(script: ESCScript, event_name: String, from_statement
## [br]
## Returns True iff event_name exists within script. Method will terminate execution of the program if the specified event is required and doesn't exist. (`bool`)
func _event_exists_in_script(script: ESCScript, event_name: String) -> bool:
- if script.events.has(event_name):
+ if script.has_event_with_target(event_name):
return true
if event_name in escoria.event_manager.REQUIRED_EVENTS:
@@ -267,7 +269,9 @@ func _event_exists_in_script(script: ESCScript, event_name: String) -> bool:
func new_game():
escoria.event_manager.interrupt() # interrupt first because we're going to reset the interpreter
escoria.game_scene.escoria_show_ui()
+ _reset_new_game_runtime_state()
escoria.globals_manager.clear()
+ escoria.object_manager.reset_game_state()
escoria.interpreter_factory.reset_interpreter()
escoria.main.clear_previous_scene()
escoria.creating_new_game = true
@@ -276,7 +280,24 @@ func new_game():
true,
true
)
- run_event_from_script(escoria.start_script, escoria.event_manager.EVENT_NEW_GAME)
+
+ var rc = await run_event_from_script(escoria.start_script, escoria.event_manager.EVENT_NEW_GAME)
+ if rc == ESCExecution.RC_OK:
+ escoria.new_game_started.emit()
+
+
+func _reset_new_game_runtime_state() -> void:
+ escoria.action_manager.clear_current_action()
+ escoria.action_manager.clear_current_tool()
+ escoria.inputs_manager.hover_stack.clear()
+ escoria.inputs_manager.hotspot_focused = ""
+ escoria.inputs_manager.input_mode = escoria.inputs_manager.INPUT_ALL
+
+ if escoria.game_scene and escoria.game_scene.is_inside_tree():
+ escoria.game_scene.clear_tooltip()
+ escoria.game_scene.close_inventory()
+ escoria.game_scene.hide_main_menu()
+ escoria.game_scene.unpause_game()
## Function called to quit the game.[br]
diff --git a/addons/escoria-core/game/main.gd b/addons/escoria-core/game/main.gd
index e9c6f84..1dd0807 100644
--- a/addons/escoria-core/game/main.gd
+++ b/addons/escoria-core/game/main.gd
@@ -25,6 +25,9 @@ var previous_scene: Node
## The Escoria context currently in wait state
var wait_level
+## Last used camera limit id by room global id
+var camera_limit_id_by_room_global_id: Dictionary = {}
+
## Reference to the scene transition node
@onready var scene_transition: ESCTransitionPlayer
@@ -180,6 +183,98 @@ func _on_wait_finished() -> void:
escoria.esc_level_runner.finished(wait_level)
+## Returns true iff the specified camera limit id can be applied to the scene.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## |camera_limit_id|`int`|The camera limit id to check.|yes|[br]
+## |scene|`Node`|The scene to check against.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns `true` if the specified camera limit id can be applied to the scene.
+func _is_valid_camera_limit_id_for_room(camera_limit_id: int, scene: Node) -> bool:
+ if scene == null or not is_instance_valid(scene):
+ return false
+ if not (scene is ESCRoom):
+ return false
+
+ var last_available_camera_limit = scene.camera_limits.size() - 1
+ return camera_limit_id >= 0 and camera_limit_id <= last_available_camera_limit
+
+
+## Clears stored camera limit id for the specified scene global id.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## |scene_global_id|`String`|The global id of the scene.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func clear_camera_limit_id_for_room(scene_global_id: String) -> void:
+ if scene_global_id == "":
+ return
+ camera_limit_id_by_room_global_id.erase(scene_global_id)
+
+
+## Returns the stored camera limit id for the scene if it is valid.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## |room|`Node`|The room to get the camera limit id for.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns the stored camera limit id for the scene if it is valid.
+func get_camera_limit_id_for_room(room: Node) -> Variant:
+ if room == null or not is_instance_valid(room):
+ return null
+ if not (room is ESCRoom):
+ return null
+ if not camera_limit_id_by_room_global_id.has(room.global_id):
+ return null
+
+ var stored_camera_limit_id = camera_limit_id_by_room_global_id[room.global_id]
+ if typeof(stored_camera_limit_id) != TYPE_INT:
+ camera_limit_id_by_room_global_id.erase(room.global_id)
+ return null
+ if not _is_valid_camera_limit_id_for_room(stored_camera_limit_id, room):
+ camera_limit_id_by_room_global_id.erase(room.global_id)
+ return null
+
+ return stored_camera_limit_id
+
+
+## Restores camera limits for a scene global id iff the id belongs to that scene.
+## [br]
+## #### Parameters[br]
+## [br]
+## |room_global_id|`String`|The room to get the camera limit id for.|yes|[br]
+## |camera_limit_id|`int`|The camera limit id to restore for the room.|yes|[br]
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func restore_camera_limit_for_room_global_id(room_global_id: String, camera_limit_id: Variant) -> void:
+ if room_global_id == "":
+ return
+ if current_scene == null or not (current_scene is ESCRoom):
+ clear_camera_limit_id_for_room(room_global_id)
+ return
+ if current_scene.global_id != room_global_id:
+ clear_camera_limit_id_for_room(room_global_id)
+ return
+ if typeof(camera_limit_id) != TYPE_INT:
+ clear_camera_limit_id_for_room(room_global_id)
+ return
+ if not _is_valid_camera_limit_id_for_room(camera_limit_id, current_scene):
+ clear_camera_limit_id_for_room(room_global_id)
+ return
+
+ set_camera_limits(camera_limit_id, current_scene)
+
+
## Set the camera limits[br]
## [br]
## #### Parameters[br]
@@ -194,8 +289,13 @@ func _on_wait_finished() -> void:
## Returns nothing.
func set_camera_limits(camera_limit_id: int = 0, scene: Node = current_scene) -> void:
var limits = {}
+ if scene == null or not is_instance_valid(scene):
+ return
+ if not (scene is ESCRoom):
+ return
+
var last_available_camera_limit = scene.camera_limits.size() - 1
- if camera_limit_id > last_available_camera_limit:
+ if camera_limit_id < 0 or camera_limit_id > last_available_camera_limit:
escoria.logger.error(
self,
"Camera3D limit %d requested. Last available camera limit is %d." % [
@@ -203,6 +303,8 @@ func set_camera_limits(camera_limit_id: int = 0, scene: Node = current_scene) ->
last_available_camera_limit
]
)
+ camera_limit_id_by_room_global_id.erase(scene.global_id)
+ return
var scene_camera_limits = scene.camera_limits[camera_limit_id]
if scene_camera_limits.size.x == 0 and scene_camera_limits.size.y == 0:
var area = Rect2()
@@ -248,6 +350,8 @@ func set_camera_limits(camera_limit_id: int = 0, scene: Node = current_scene) ->
escoria.object_manager.get_object(
escoria.object_manager.CAMERA
).node.set_limits(limits)
+ camera_limit_id_by_room_global_id[scene.global_id] = camera_limit_id
+
## Save the game state to the provided savegame resource.[br]
@@ -402,3 +506,19 @@ func show_ui() -> void:
func show_current_scene() -> void:
if escoria.main.current_scene != null:
escoria.main.current_scene.show()
+
+## Stops all sound channels.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns nothing.
+func stop_all_sounds() -> void:
+ var stop_snd: StopSndCommand = StopSndCommand.new()
+ stop_snd.run([ESCObjectManager.AMBIENT])
+ stop_snd.run([ESCObjectManager.MUSIC])
+ stop_snd.run([ESCObjectManager.SOUND])
+ stop_snd.run([ESCObjectManager.SPEECH])
diff --git a/addons/escoria-core/game/main_scene.gd b/addons/escoria-core/game/main_scene.gd
index 423c425..6701703 100644
--- a/addons/escoria-core/game/main_scene.gd
+++ b/addons/escoria-core/game/main_scene.gd
@@ -1,8 +1,8 @@
## Main_scene is the entry point for Godot Engine.
##
## This scene sets up the main scene to load.
-extends Node
class_name ESCMain
+extends Node
## Reference to the Escoria node instance.
var escoria_node: Escoria
diff --git a/addons/escoria-core/game/scenes/camera_player/camera.tscn b/addons/escoria-core/game/scenes/camera_player/camera.tscn
index 4dc2e9c..34def51 100644
--- a/addons/escoria-core/game/scenes/camera_player/camera.tscn
+++ b/addons/escoria-core/game/scenes/camera_player/camera.tscn
@@ -1,9 +1,8 @@
-[gd_scene load_steps=2 format=3 uid="uid://dmw5gicuenj53"]
+[gd_scene format=3 uid="uid://dmw5gicuenj53"]
[ext_resource type="Script" uid="uid://b8xyisawuhtw3" path="res://addons/escoria-core/game/scenes/camera_player/esc_camera.gd" id="1"]
-[node name="camera" type="Camera2D"]
-current = true
+[node name="camera" type="Camera2D" unique_id=636237273]
drag_horizontal_enabled = true
drag_vertical_enabled = true
script = ExtResource("1")
diff --git a/addons/escoria-core/game/scenes/camera_player/esc_camera.gd b/addons/escoria-core/game/scenes/camera_player/esc_camera.gd
index 6473121..b97f050 100644
--- a/addons/escoria-core/game/scenes/camera_player/esc_camera.gd
+++ b/addons/escoria-core/game/scenes/camera_player/esc_camera.gd
@@ -1,6 +1,6 @@
## Camera handling for Escoria scenes.
-extends Camera2D
class_name ESCCamera
+extends Camera2D
## Reference to the tween node for animating camera movements.
var _tween: Tween3:
@@ -43,27 +43,6 @@ func _process(_delta):
if is_instance_valid(_follow_target) and not _tween.is_running() and _follow_target.has_moved():
self.global_position = _follow_target.global_position
-## Register this camera with the object manager so it can be used before being made active as part of the current scene tree.[br]
-## [br]
-## #### Parameters[br]
-## [br]
-## | Name | Type | Description | Required? |[br]
-## |:-----|:-----|:------------|:----------|[br]
-## |room|`Variant`|The room with which to register the camera|no|[br]
-## [br]
-## #### Returns[br]
-## [br]
-## Returns nothing.
-func register(room = null):
- escoria.object_manager.register_object(
- ESCObject.new(
- escoria.object_manager.CAMERA,
- self
- ),
- room,
- true
- )
-
## The camera's tween instance.[br]
## [br]
## #### Parameters[br]
diff --git a/addons/escoria-core/game/scenes/camera_player/esc_camera_limits.gd b/addons/escoria-core/game/scenes/camera_player/esc_camera_limits.gd
index 316e392..292896c 100644
--- a/addons/escoria-core/game/scenes/camera_player/esc_camera_limits.gd
+++ b/addons/escoria-core/game/scenes/camera_player/esc_camera_limits.gd
@@ -1,6 +1,6 @@
## Describes a bounding box that limits the camera movement in the scene.
-extends RefCounted
class_name ESCCameraLimits
+extends RefCounted
## The left side of the bounding box.
var limit_left: int = -10000
diff --git a/addons/escoria-core/game/scenes/dialogs/esc_dialog_manager.gd b/addons/escoria-core/game/scenes/dialogs/esc_dialog_manager.gd
index 0a00456..375c967 100644
--- a/addons/escoria-core/game/scenes/dialogs/esc_dialog_manager.gd
+++ b/addons/escoria-core/game/scenes/dialogs/esc_dialog_manager.gd
@@ -1,7 +1,7 @@
## A base class for dialog plugins to work with Escoria
## @MANAGER
-extends Control
class_name ESCDialogManager
+extends Control
## Emitted when the say function has completed showing the text[br]
## [br]
@@ -40,7 +40,7 @@ signal option_chosen(option)
## #### Returns[br]
## [br]
## Returns whether the type is supported or not. (`bool`)
-func has_type(type: String) -> bool:
+func has_type(_type: String) -> bool:
return false
## Checks whether a specific chooser type is supported by the dialog plugin.[br]
@@ -54,7 +54,7 @@ func has_type(type: String) -> bool:
## #### Returns[br]
## [br]
## Returns whether the type is supported or not. (`bool`)
-func has_chooser_type(type: String) -> bool:
+func has_chooser_type(_type: String) -> bool:
return false
## Outputs a text said by the item specified by the global id and emits `say_finished` after finishing displaying the text.[br]
@@ -72,7 +72,7 @@ func has_chooser_type(type: String) -> bool:
## #### Returns[br]
## [br]
## Returns nothing.
-func say(dialog_player: Node, global_id: String, text: String, type: String, key: String):
+func say(_dialog_player: Node, _global_id: String, _text: String, _type: String, _key: String):
pass
## Instructs the dialog manager to preserve the next dialog box used by a `say` command until a call to `disable_preserve_dialog_box` is made. This method should be idempotent, i.e. if called after the first time and prior to `disable_preserve_dialog_box` being called, the result should be the same.[br]
@@ -112,7 +112,7 @@ func disable_preserve_dialog_box() -> void:
## #### Returns[br]
## [br]
## Returns nothing.
-func choose(dialog_player: Node, dialog: ESCDialog, type: String):
+func choose(_dialog_player: Node, _dialog: ESCDialog, _type: String):
pass
## Triggers running the dialogue faster.[br]
diff --git a/addons/escoria-core/game/scenes/dialogs/esc_dialog_options_chooser.gd b/addons/escoria-core/game/scenes/dialogs/esc_dialog_options_chooser.gd
index 44e2a3f..69f7127 100644
--- a/addons/escoria-core/game/scenes/dialogs/esc_dialog_options_chooser.gd
+++ b/addons/escoria-core/game/scenes/dialogs/esc_dialog_options_chooser.gd
@@ -1,6 +1,6 @@
## Base class for all dialog options implementations
-extends Control
class_name ESCDialogOptionsChooser
+extends Control
## Emitted when an option is chosen.[br]
## [br]
diff --git a/addons/escoria-core/game/scenes/dialogs/esc_dialog_player.gd b/addons/escoria-core/game/scenes/dialogs/esc_dialog_player.gd
index 70e61e2..3862696 100644
--- a/addons/escoria-core/game/scenes/dialogs/esc_dialog_player.gd
+++ b/addons/escoria-core/game/scenes/dialogs/esc_dialog_player.gd
@@ -1,6 +1,6 @@
## Escoria dialog player
-extends Control
class_name ESCDialogPlayer
+extends Control
## Emitted when an answer is chosen.[br]
## [br]
@@ -158,9 +158,9 @@ func _determine_say_dialog_manager(type: String) -> void:
ESCProjectSettingsManager.DIALOG_MANAGERS
):
if ResourceLoader.exists(_manager_class):
- var _manager: ESCDialogManager = load(_manager_class).new()
- if _manager.has_type(type):
- dialog_manager = _manager
+ var manager: ESCDialogManager = load(_manager_class).new()
+ if manager.has_type(type):
+ dialog_manager = manager
else:
dialog_manager = null
@@ -191,9 +191,9 @@ func _determine_choose_dialog_manager(type: String) -> void:
ESCProjectSettingsManager.DIALOG_MANAGERS
):
if ResourceLoader.exists(_manager_class):
- var _manager: ESCDialogManager = load(_manager_class).new()
- if _manager.has_chooser_type(type):
- dialog_manager = _manager
+ var manager: ESCDialogManager = load(_manager_class).new()
+ if manager.has_chooser_type(type):
+ dialog_manager = manager
else:
dialog_manager = null
@@ -248,7 +248,8 @@ func _determine_dialog_manager(dialog_type: String, dialog_manager_type: String)
if dialog_type == DIALOG_TYPE_SAY:
_determine_say_dialog_manager(dialog_manager_type)
return _say_dialog_manager
- elif dialog_type == DIALOG_TYPE_CHOOSE:
+
+ if dialog_type == DIALOG_TYPE_CHOOSE:
_determine_choose_dialog_manager(dialog_manager_type)
return _choose_dialog_manager
diff --git a/addons/escoria-core/game/scenes/esc_prompt/esc_prompt_popup.gd b/addons/escoria-core/game/scenes/esc_prompt/esc_prompt_popup.gd
index d538036..b8d92b2 100644
--- a/addons/escoria-core/game/scenes/esc_prompt/esc_prompt_popup.gd
+++ b/addons/escoria-core/game/scenes/esc_prompt/esc_prompt_popup.gd
@@ -1,14 +1,8 @@
## A debug window which can run esc commands
extends Window
-## Reference to the past actions display
-@onready var past_actions = $VBoxContainer/past_actions
-
-## Reference to the command input
-@onready var command = $VBoxContainer/command
-
-## ESC commands kept around for references to their command names.
-var _print: PrintCommand
+## The maximum number of commands to keep in history
+const COMMANDS_HISTORY_LENGTH: int = 20
## History of typed commands
var commands_history: PackedStringArray
@@ -16,8 +10,14 @@ var commands_history: PackedStringArray
## The current index in the command history
var commands_history_current_id: int
-## The maximum number of commands to keep in history
-const COMMANDS_HISTORY_LENGTH: int = 20
+## ESC commands kept around for references to their command names.
+var _print: PrintCommand
+
+## Reference to the past actions display
+@onready var past_actions = $VBoxContainer/past_actions
+
+## Reference to the command input
+@onready var command = $VBoxContainer/command
## Called when the node is added to the scene tree for the first time.[br]
## [br]
@@ -111,7 +111,7 @@ func _on_command_text_entered(p_command_str : String):
if script:
escoria.logger.dont_assert = true
- escoria.event_manager.queue_event(script.events[escoria.event_manager.EVENT_PRINT])
+ escoria.event_manager.queue_event(script.get_event_with_target(escoria.event_manager.EVENT_PRINT))
var ret = await escoria.event_manager.event_finished
while ret[1] != _print.get_command_name():
ret = await escoria.event_manager.event_finished
diff --git a/addons/escoria-core/game/scenes/inventory/inventory_ui.gd b/addons/escoria-core/game/scenes/inventory/inventory_ui.gd
index 082c250..e3e8dcf 100644
--- a/addons/escoria-core/game/scenes/inventory/inventory_ui.gd
+++ b/addons/escoria-core/game/scenes/inventory/inventory_ui.gd
@@ -1,6 +1,6 @@
## Manages the inventory on the GUI connected to the inventory_ui_container variable.
-extends Control
class_name ESCInventory
+extends Control
## The actual container node to add items as children of. Should be a Container.
@export var inventory_ui_container: NodePath
@@ -167,7 +167,7 @@ func remove_item_by_id(item_id: String) -> void:
## #### Returns[br]
## [br]
## Returns nothing.
-func _on_escoria_global_changed(global: String, old_value, new_value) -> void:
+func _on_escoria_global_changed(global: String, _old_value, new_value) -> void:
if !global.begins_with("i/"):
return
var item = global.rsplit("i/", false)
diff --git a/addons/escoria-core/game/scenes/sound/esc_ambient_player.gd b/addons/escoria-core/game/scenes/sound/esc_ambient_player.gd
index 298e7db..1ab7f32 100644
--- a/addons/escoria-core/game/scenes/sound/esc_ambient_player.gd
+++ b/addons/escoria-core/game/scenes/sound/esc_ambient_player.gd
@@ -1,6 +1,6 @@
## Background ambient sound player
-extends Control
class_name ESCAmbientPlayer
+extends Control
## Global id of the background ambient sound player.
@export var global_id: String = "_ambient"
diff --git a/addons/escoria-core/game/scenes/sound/esc_music_player.gd b/addons/escoria-core/game/scenes/sound/esc_music_player.gd
index b8f6c45..5cef646 100644
--- a/addons/escoria-core/game/scenes/sound/esc_music_player.gd
+++ b/addons/escoria-core/game/scenes/sound/esc_music_player.gd
@@ -1,6 +1,6 @@
## Background music player
-extends Control
class_name ESCMusicPlayer
+extends Control
## Global id of the background music player.
@export var global_id: String = "_music"
diff --git a/addons/escoria-core/game/scenes/sound/esc_sound_player.gd b/addons/escoria-core/game/scenes/sound/esc_sound_player.gd
index 2a9fa64..f18abcc 100644
--- a/addons/escoria-core/game/scenes/sound/esc_sound_player.gd
+++ b/addons/escoria-core/game/scenes/sound/esc_sound_player.gd
@@ -1,6 +1,6 @@
## Background sound player
-extends Control
class_name ESCSoundPlayer
+extends Control
## Global id of the sfx sound player.
@export var global_id: String = "_sound"
diff --git a/addons/escoria-core/game/scenes/sound/esc_speech_player.gd b/addons/escoria-core/game/scenes/sound/esc_speech_player.gd
index 31fc668..34c0483 100644
--- a/addons/escoria-core/game/scenes/sound/esc_speech_player.gd
+++ b/addons/escoria-core/game/scenes/sound/esc_speech_player.gd
@@ -1,6 +1,6 @@
## Speech player
-extends Control
class_name ESCSpeechPlayer
+extends Control
## Global id of the speech player.
@export var global_id: String = "_speech"
@@ -21,7 +21,7 @@ class_name ESCSpeechPlayer
## #### Returns[br]
## [br]
## Returns nothing.
-func set_state(p_state: String, from_seconds: float = 0.0, p_force: bool = false) -> void:
+func set_state(p_state: String, from_seconds: float = 0.0, _p_force: bool = false) -> void:
# If speech is disabled, return
if not ESCProjectSettingsManager.get_setting(
ESCProjectSettingsManager.SPEECH_ENABLED
diff --git a/addons/escoria-core/game/scenes/transitions/esc_transition_player.gd b/addons/escoria-core/game/scenes/transitions/esc_transition_player.gd
index 40b0b61..1bf71cf 100644
--- a/addons/escoria-core/game/scenes/transitions/esc_transition_player.gd
+++ b/addons/escoria-core/game/scenes/transitions/esc_transition_player.gd
@@ -1,6 +1,6 @@
## A transition player for scene changes
-extends ColorRect
class_name ESCTransitionPlayer
+extends ColorRect
## Emitted when the transition was played[br]
## [br]
@@ -13,7 +13,7 @@ class_name ESCTransitionPlayer
signal transition_done(transition_id)
## The valid transition modes
-enum TRANSITION_MODE {
+enum TransitionMode {
IN,
OUT
}
@@ -68,7 +68,7 @@ func _ready() -> void:
## Returns the transition id. (`int`)
func transition(
transition_name: String = "",
- mode: int = TRANSITION_MODE.IN,
+ mode: int = TransitionMode.IN,
duration: float = 1.0
) -> int:
@@ -105,7 +105,7 @@ func transition(
var start = 0.0
var end = 1.0
- if mode == TRANSITION_MODE.OUT:
+ if mode == TransitionMode.OUT:
start = 1.0
end = 0.0
diff --git a/addons/escoria-core/game/tween3.gd b/addons/escoria-core/game/tween3.gd
index 88a3e8a..ee8f07a 100644
--- a/addons/escoria-core/game/tween3.gd
+++ b/addons/escoria-core/game/tween3.gd
@@ -1,7 +1,27 @@
## In Godot 4, tweens cannot be resumed. But if you re-create them,[br]
## the bindings are lost. So this class wraps a tween and offers a reset function.
-extends RefCounted
class_name Tween3
+extends RefCounted
+
+
+## Emitted when the tween finishes.[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+signal finished()
+
+
+## The parent node for the tween.
+var _tween_parent: Node
+
+## The Tween instance being managed.
+var _tween: Tween
+
+## The duration of the tween.
+var _duration: float
+
## Interpolates a property on an object using a tween.[br]
## [br]
@@ -44,23 +64,6 @@ static func tween_interpolate_property(
return true
-## The parent node for the tween.
-var _tween_parent: Node
-
-## The Tween instance being managed.
-var _tween: Tween
-
-## The duration of the tween.
-var _duration: float
-
-## Emitted when the tween finishes.[br]
-## [br]
-## #### Parameters[br]
-## [br]
-## None.
-## [br]
-signal finished()
-
## Called when the tween finishes.[br]
## [br]
## #### Parameters[br]
@@ -100,7 +103,22 @@ func _init(tween_parent: Node):
## [br]
## Returns nothing.
func _create_tween():
- _tween = _tween_parent.get_tree().create_tween()
+ var tree = _tween_parent.get_tree()
+ if tree == null:
+ escoria.logger.error(
+ self,
+ "Tween3: Parent node is not in the scene tree. Cannot create tween."
+ )
+ return
+
+ _tween = tree.create_tween()
+ if _tween == null:
+ escoria.logger.error(
+ self,
+ "Tween3: Failed to create tween."
+ )
+ return
+
_tween.pause() # prevent autoplay
_tween.finished.connect(_on_finished)
diff --git a/addons/escoria-core/patterns/state_machine/state.gd b/addons/escoria-core/patterns/state_machine/state.gd
index cfa9e0f..0d28ac2 100644
--- a/addons/escoria-core/patterns/state_machine/state.gd
+++ b/addons/escoria-core/patterns/state_machine/state.gd
@@ -1,5 +1,5 @@
-extends Node
class_name State
+extends Node
## Base interface for all states.
##
## This class doesn't do anything in itself but forces us to pass the right
diff --git a/addons/escoria-core/patterns/state_machine/state_machine.gd b/addons/escoria-core/patterns/state_machine/state_machine.gd
index 2fa5d96..95a3e64 100644
--- a/addons/escoria-core/patterns/state_machine/state_machine.gd
+++ b/addons/escoria-core/patterns/state_machine/state_machine.gd
@@ -1,5 +1,5 @@
-extends Node
class_name StateMachine
+extends Node
## Base interface for a generic state machine
## It handles initializing, setting the machine active or not
## delegating _physics_process, _input calls to the State nodes,
@@ -15,9 +15,9 @@ class_name StateMachine
## [br]
signal state_changed(current_state)
-## Starting state. This node has to be set *before* the initialize(START_STATE)
+## Starting state. This node has to be set *before* the initialize(start_state)
## command is called.
-var START_STATE: Node
+var start_state: Node
## List of states
var states_map = {}
@@ -48,13 +48,14 @@ var _active = false:
## [br]
## Returns nothing.
func initialize(start_state: State):
- if START_STATE == null:
+ if start_state == null:
escoria.logger.error(
self,
- "Starting state is required to be initialized with a defined state,
- but it is null. Escoria cannot determine which of the defined states
- (in states_map dictionary) is supposed to be the starting one.
- Please assign a state to START_STATE in your implementation of the StateMachine class.")
+ "Starting state is required to be initialized with a defined state," + \
+ "but it is null. Escoria cannot determine which of the defined states" + \
+ "(in states_map dictionary) is supposed to be the starting one." + \
+ "Please assign a state to start_state in your implementation of the StateMachine class.")
+ return
for child in get_children():
child.connect("finished", Callable(self, "_change_state"))
diff --git a/addons/escoria-core/plugin.gd b/addons/escoria-core/plugin.gd
index 8c5dc09..0fb8dab 100644
--- a/addons/escoria-core/plugin.gd
+++ b/addons/escoria-core/plugin.gd
@@ -2,6 +2,8 @@
## Plugin script to initialize Escoria
extends EditorPlugin
+
+
## Comma separator const used to build enabled extensions.
const COMMA_SEPARATOR = ","
@@ -13,6 +15,7 @@ const ASHES_ANALYZER_MENU_ITEM = "Analyze ASHES Scripts"
## The warning popup displayed on escoria-core enabling.
var popup_info: AcceptDialog
+var popup_migration: AcceptDialogMigrate4647
## ASHES scripts analyzer. Needed to allow calling the analyzer from
## Project>Tools menu.
@@ -33,6 +36,7 @@ func _enable_plugin():
"escoria",
"res://addons/escoria-core/game/esc_autoload.gd"
)
+
# Prepare settings
_set_escoria_main_settings()
_set_escoria_debug_settings()
@@ -62,7 +66,6 @@ func _enable_plugin():
get_editor_interface().get_editor_main_screen().add_child(popup_info)
popup_info.popup_centered()
-
## Callback for warning popup displayed on escoria-core plugin enabling.[br]
## [br]
## #### Parameters[br]
@@ -73,7 +76,7 @@ func _enable_plugin():
## [br]
## Returns nothing.
func _on_warning_popup_confirmed():
- popup_info.queue_free()
+ popup_info.hide()
## Virtual function called when plugin is disabled.[br]
@@ -103,6 +106,13 @@ func _enter_tree():
# have to add this here since reloading the project doesn't re-add the Tools menu item
add_tool_menu_item(ASHES_ANALYZER_MENU_ITEM, _compiler_analyzer.analyze)
+ popup_migration = preload("res://addons/escoria-core/editor/migration_4_6_4_7/acceptdialog_migrate_4_6_4_7.tscn").instantiate()
+ get_editor_interface().get_base_control().add_child(popup_migration)
+ if popup_migration.required_migration:
+ popup_migration.popup_centered()
+ else:
+ get_editor_interface().get_base_control().remove_child(popup_migration)
+
## Called when Escoria plugin gets removed from Godot Editor's tree.[br]
## [br]
@@ -307,6 +317,14 @@ func _set_escoria_main_settings():
}
)
+ register_setting(
+ ESCProjectSettingsManager.SAVE_SOUNDS_PLAYBACK_POSITION,
+ true,
+ {
+ "type": TYPE_BOOL,
+ }
+ )
+
## Prepare the settings in the Escoria debug category[br]
## [br]
diff --git a/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd b/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd
new file mode 100644
index 0000000..e03d122
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd
@@ -0,0 +1,1879 @@
+# GdUnit generated TestSuite
+class_name ESCInterpreterTest
+extends GdUnitTestSuite
+
+@warning_ignore("unused_parameter")
+@warning_ignore("return_value_discarded")
+
+const MARK_SCENE := preload("res://game/characters/mark/mark.tscn")
+const SIMPLE_CHOOSER_SCENE := preload("res://addons/escoria-dialog-simple/chooser/simple.tscn")
+
+var _terminate_on_errors_before: bool
+var _command_directories_before: PackedStringArray
+var _dialog_player_before
+
+
+class DialogPlayerDouble extends ESCDialogPlayer:
+ var _choice_indexes: Array = []
+ var _choices_consumed: int = 0
+ var _dialogs_started: Array = []
+
+
+ func _init(choice_indexes: Array = []):
+ _choice_indexes = choice_indexes.duplicate()
+
+
+ func start_dialog_choices(dialog: ESCDialog, _type: String = "simple"):
+ var option_keys: Array = []
+ var option_texts: Array = []
+ var option_source_indexes: Array = []
+ for option in dialog.options:
+ option_keys.append(option.translation_key)
+ option_texts.append(option.option)
+ option_source_indexes.append(option.source_option_index)
+
+ var timeout_option := dialog.get_timeout_option()
+
+ _dialogs_started.append({
+ "avatar": dialog.avatar,
+ "timeout": dialog.timeout,
+ "timeout_option": dialog.timeout_option,
+ "authored_options_count": dialog.authored_options_count,
+ "visible_timeout_option": timeout_option.option if timeout_option else null,
+ "options_size": dialog.options.size(),
+ "option_keys": option_keys,
+ "option_texts": option_texts,
+ "option_source_indexes": option_source_indexes
+ })
+
+ # Mirror the real dialog player closely enough for interpreter tests:
+ # only valid options are choosable, and scripted indexes deterministically
+ # drive nested dialog flows without needing UI interaction.
+ var valid_options: Array = []
+ for option in dialog.options:
+ if option.is_valid():
+ valid_options.append(option)
+
+ var choice_index := 0
+ if not _choice_indexes.is_empty():
+ choice_index = int(_choice_indexes.pop_front())
+ _choices_consumed += 1
+
+ call_deferred("_emit_choice", valid_options[choice_index] if choice_index < valid_options.size() else null)
+
+
+ func _emit_choice(option) -> void:
+ option_chosen.emit(option)
+
+
+ func get_choices_consumed() -> int:
+ return _choices_consumed
+
+
+ func get_remaining_choices() -> int:
+ return _choice_indexes.size()
+
+
+ func get_dialogs_started() -> Array:
+ return _dialogs_started.duplicate(true)
+
+
+func before() -> void:
+ # Interpreter tests intentionally trigger runtime errors in a few cases, so
+ # they disable terminate-on-error and install a test-only command directory.
+ # The dialog player is also replaced on demand by a deterministic test
+ # double so dialog execution can run headlessly.
+ ESCInterpreterFactory.reset_interpreter()
+ escoria.event_manager.interrupt([], false)
+ escoria.event_manager.clear_event_queue()
+ _terminate_on_errors_before = ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.TERMINATE_ON_ERRORS
+ )
+ _command_directories_before = PackedStringArray(
+ ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.COMMAND_DIRECTORIES
+ )
+ )
+ _dialog_player_before = escoria.get("dialog_player")
+
+ ESCProjectSettingsManager.set_setting(
+ ESCProjectSettingsManager.TERMINATE_ON_ERRORS,
+ false
+ )
+ ESCProjectSettingsManager.set_setting(
+ ESCProjectSettingsManager.COMMAND_DIRECTORIES,
+ PackedStringArray([
+ "res://addons/escoria-core/testing/ashes/interpreter/support/commands"
+ ] + Array(_command_directories_before))
+ )
+ escoria.command_registry.registry.clear()
+ escoria.set("dialog_player", null)
+
+
+func after() -> void:
+ # Restore shared Escoria settings and globals so each test starts from the
+ # same baseline and does not leak command registry or dialog-player state.
+ ESCInterpreterFactory.reset_interpreter()
+ escoria.event_manager.interrupt([], false)
+ escoria.event_manager.clear_event_queue()
+ ESCProjectSettingsManager.set_setting(
+ ESCProjectSettingsManager.TERMINATE_ON_ERRORS,
+ _terminate_on_errors_before
+ )
+ ESCProjectSettingsManager.set_setting(
+ ESCProjectSettingsManager.COMMAND_DIRECTORIES,
+ _command_directories_before
+ )
+ escoria.command_registry.registry.clear()
+ escoria.set("dialog_player", _dialog_player_before)
+
+
+func test_execute_block_restores_scope_after_break() -> void:
+ # This fixture creates a nested block-local `x`, exits that block with
+ # `break`, and then reads `x` afterward into a global. If `_execute_block()`
+ # fails to restore `_environment` on the early return, the later lookup will
+ # incorrectly resolve the inner `x` instead of the outer one.
+ var source := _load_fixture("block_scope_restores_after_break.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("block_scope_restores_after_break.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new([], {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+
+ await interpreter.interpret(statements)
+
+ var globals := interpreter.get_global_values()
+ assert_bool(globals.has("result")).is_true()
+ assert_float(float(globals["result"])).is_equal(1.0)
+
+ interpreter.cleanup()
+
+
+func test_invalid_numeric_comparison_does_not_succeed_silently() -> void:
+ # This fixture performs an invalid numeric comparison. Invalid numeric
+ # operations should log an interpreter error and evaluate to `null` rather
+ # than continuing into a native GDScript operator error.
+ var source := _load_fixture("invalid_numeric_comparison.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("invalid_numeric_comparison.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new([], {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+
+ await interpreter.interpret(statements)
+
+ var globals := interpreter.get_global_values()
+ assert_bool(globals.has("result")).is_true()
+ assert_bool(globals["result"] == null).is_true()
+
+ interpreter.cleanup()
+
+
+func test_print_with_zero_arguments_does_not_native_crash() -> void:
+ # This fixture calls the builtin `print()` with zero arguments. The current
+ # implementation only rejects calls with more than one argument, so this
+ # test captures the actual runtime behavior before we decide on a fix.
+ var source := _load_fixture("print_zero_args.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("print_zero_args.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new([], {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+
+ await interpreter.interpret(statements)
+
+ interpreter.cleanup()
+
+
+func test_print_with_two_arguments_does_not_native_crash() -> void:
+ # This fixture calls the builtin `print()` with too many arguments. The
+ # builtin should reject the call cleanly and avoid any native runtime error.
+ var source := _load_fixture("print_two_args.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("print_two_args.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new([], {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+
+ await interpreter.interpret(statements)
+
+ interpreter.cleanup()
+
+
+func test_stop_in_nested_block_prevents_later_statements() -> void:
+ # `stop` should unwind the current event immediately, even when it is issued
+ # from inside a nested block. The later assignment must therefore never run.
+ var outcome := await _interpret_fixture("stop_nested_block.esc")
+ var globals: Dictionary = outcome.globals
+ assert_bool(globals.has("result")).is_true()
+ assert_float(float(globals["result"])).is_equal(0.0)
+
+
+func test_done_in_nested_dialog_block_prevents_later_statements() -> void:
+ # `done` should conclude the current dialog from inside a nested block, but
+ # it must not abort the rest of the event. The assignment after the dialog
+ # should still execute, proving the dialog exits cleanly without leaking a
+ # control-flow sentinel to the enclosing event body.
+ var outcome := await _interpret_fixture(
+ "done_nested_block_in_dialog.esc",
+ [0]
+ )
+ var globals: Dictionary = outcome.globals
+ assert_bool(globals.has("result")).is_true()
+ assert_float(float(globals["result"])).is_equal(1.0)
+
+
+func test_break_one_returns_to_parent_dialog_level() -> void:
+ # `break 1` should move dialog control back up one level, not continue the
+ # remainder of the current outer option body. The scripted choice sequence
+ # must therefore consume a third outer-dialog choice after the nested break;
+ # if the dialog ends early instead, the final string alone is ambiguous.
+ var outcome := await _interpret_fixture(
+ "nested_dialog_break_one.esc",
+ [0, 0, 1]
+ )
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("start-done")
+ assert_int(int(outcome.dialog_choices_consumed)).is_equal(3)
+ assert_int(int(outcome.remaining_dialog_choices)).is_equal(0)
+
+
+func test_done_exits_entire_nested_dialog_tree() -> void:
+ # `done` should conclude the entire dialog from any nesting depth. After the
+ # nested choice issues `done`, the outer option body must not continue and
+ # the outer dialog must not be shown again. Execution should resume only
+ # after the dialog statement, so the final value should reflect the initial
+ # value plus the post-dialog append.
+ var outcome := await _interpret_fixture(
+ "done_exits_entire_nested_dialog.esc",
+ [0, 0]
+ )
+ var globals: Dictionary = outcome.globals
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("start-done")
+
+
+func test_dialog_followup_runs_after_all_options_are_exhausted() -> void:
+ # When each option invalidates itself, the dialog should conclude once no
+ # valid options remain. Execution must then continue with the statement after
+ # the dialog block.
+ var outcome := await _interpret_fixture(
+ "dialog_exhausted_options_runs_followup.esc",
+ [0, 0]
+ )
+ var globals: Dictionary = outcome.globals
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("start-say1-say2-finished")
+ assert_bool(bool(globals["option1_is_valid"])).is_false()
+ assert_bool(bool(globals["option2_is_valid"])).is_false()
+ assert_int(int(outcome.dialog_choices_consumed)).is_equal(2)
+ assert_int(int(outcome.remaining_dialog_choices)).is_equal(0)
+
+
+func test_break_two_exits_two_dialog_levels() -> void:
+ # `break 2` from the innermost dialog should move back up two dialog levels
+ # to the outermost dialog in this fixture shape. As with `break 1`, the test
+ # also verifies that the final scripted outer choice was actually consumed,
+ # which proves the outer dialog stayed alive after the nested unwind.
+ var outcome := await _interpret_fixture(
+ "break_two_exits_two_dialog_levels.esc",
+ [0, 0, 0, 1]
+ )
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("start-done")
+ assert_int(int(outcome.dialog_choices_consumed)).is_equal(4)
+ assert_int(int(outcome.remaining_dialog_choices)).is_equal(0)
+
+
+func test_stop_in_dialog_option_prevents_later_event_statements() -> void:
+ # `stop` issued from a dialog option should abort the entire event, not just
+ # the current dialog. The assignment after the dialog must therefore never run.
+ var outcome := await _interpret_fixture(
+ "stop_in_dialog_option.esc",
+ [0]
+ )
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_float(float(outcome.globals["result"])).is_equal(0.0)
+
+
+func test_delayed_command_in_dialog_option_preserves_ordering() -> void:
+ # A blocking command inside a dialog option must complete before the option
+ # body resumes and before execution continues after the dialog. If it does
+ # not block correctly, the final append would observe `unset` instead of the
+ # command-written value.
+ var outcome := await _interpret_fixture(
+ "delayed_command_in_dialog_option.esc",
+ [0]
+ )
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("cmd-after")
+
+
+func test_delayed_command_in_loop_preserves_ordering() -> void:
+ # A blocking command inside a loop body must finish before the loop can
+ # continue to the next statement. The later append therefore proves the
+ # delayed command completed before the event resumed after the loop.
+ var outcome := await _interpret_fixture("delayed_command_in_loop.esc")
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("cmd-after")
+ assert_bool(outcome.globals.has("count")).is_true()
+ assert_float(float(outcome.globals["count"])).is_equal(1.0)
+
+
+func test_interrupting_delayed_command_stops_event_progress() -> void:
+ # Interrupting an in-flight command should stop the surrounding event from
+ # resuming normal execution. The command records that it observed the
+ # interrupt, and the later append must never run.
+ var source := _load_fixture("delayed_command_interrupt.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("delayed_command_interrupt.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ var event_stmt: ESCGrammarStmts.Event = statements[0]
+ interpreter.call_deferred("interpret", statements)
+ await escoria.get_tree().create_timer(0.05).timeout
+ interpreter.interrupt()
+ await event_stmt.finished
+
+ var globals := interpreter.get_global_values()
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("start")
+ assert_bool(globals.has("was_interrupted")).is_true()
+ assert_bool(bool(globals["was_interrupted"])).is_true()
+ assert_bool(event_stmt.is_interrupted()).is_true()
+
+ interpreter.cleanup()
+
+
+func test_interrupting_delayed_command_in_dialog_stops_event_progress() -> void:
+ # Interrupting an in-flight command inside a dialog option should abort the
+ # surrounding event just as cleanly as interruption in a plain block. The
+ # dialog must not resume and the post-dialog append must never run.
+ var source := _load_fixture("delayed_command_interrupt_in_dialog.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("delayed_command_interrupt_in_dialog.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+ var dialog_player := DialogPlayerDouble.new([0])
+ escoria.set("dialog_player", dialog_player)
+
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ var event_stmt: ESCGrammarStmts.Event = statements[0]
+ interpreter.call_deferred("interpret", statements)
+ await escoria.get_tree().create_timer(0.05).timeout
+ interpreter.interrupt()
+ await event_stmt.finished
+
+ var globals := interpreter.get_global_values()
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("start")
+ assert_bool(globals.has("was_interrupted")).is_true()
+ assert_bool(bool(globals["was_interrupted"])).is_true()
+ assert_bool(event_stmt.is_interrupted()).is_true()
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_interrupting_delayed_command_stops_multi_iteration_loop() -> void:
+ # Interrupting a delayed command in the first loop iteration should stop the
+ # loop itself, not just the current statement. The counter must therefore
+ # stay at zero and the post-loop append must never run.
+ var source := _load_fixture("delayed_command_interrupt_in_multi_loop.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("delayed_command_interrupt_in_multi_loop.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ var event_stmt: ESCGrammarStmts.Event = statements[0]
+ interpreter.call_deferred("interpret", statements)
+ await escoria.get_tree().create_timer(0.05).timeout
+ interpreter.interrupt()
+ await event_stmt.finished
+
+ var globals := interpreter.get_global_values()
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("start")
+ assert_bool(globals.has("was_interrupted")).is_true()
+ assert_bool(bool(globals["was_interrupted"])).is_true()
+ assert_bool(globals.has("count")).is_true()
+ assert_float(float(globals["count"])).is_equal(0.0)
+ assert_bool(event_stmt.is_interrupted()).is_true()
+
+ interpreter.cleanup()
+
+
+func test_invalid_command_does_not_continue_event() -> void:
+ # A missing command should fail the current event cleanly instead of letting
+ # later statements continue as if the command had succeeded.
+ var outcome := await _interpret_fixture("invalid_command_does_not_continue.esc")
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("start")
+
+
+func test_invalid_command_in_dialog_does_not_continue_event() -> void:
+ # A missing command inside a dialog option should still terminate the event
+ # cleanly. The assignment after the dialog must not run.
+ var outcome := await _interpret_fixture(
+ "invalid_command_in_dialog_stops_event.esc",
+ [0]
+ )
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("start")
+
+
+func test_invalid_command_in_loop_does_not_continue_event() -> void:
+ # A missing command inside a loop body should escape the loop as a terminal
+ # runtime error instead of allowing later loop or post-loop statements to run.
+ var outcome := await _interpret_fixture("invalid_command_in_loop_stops_event.esc")
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("start")
+ assert_bool(outcome.globals.has("count")).is_true()
+ assert_float(float(outcome.globals["count"])).is_equal(0.0)
+
+
+func test_script_preserves_multi_target_use_events_without_creating_untargeted_use() -> void:
+ # Room05-style scripts can define the same action multiple times with
+ # different targets. Both targeted events must survive compilation, while an
+ # untargeted lookup for the same action must not accidentally match either.
+ var script_object := _compile_fixture_script("multi_target_use_events.esc")
+
+ assert_bool(script_object.has_event_with_target("use", "r5_wrench")).is_true()
+ assert_bool(script_object.has_event_with_target("use", "r5_pen")).is_true()
+ assert_bool(script_object.has_event_with_target("use")).is_false()
+
+ var wrench_event = script_object.get_event_with_target("use", "r5_wrench")
+ var pen_event = script_object.get_event_with_target("use", "r5_pen")
+
+ assert_object(wrench_event).is_not_null()
+ assert_object(pen_event).is_not_null()
+ assert_object(wrench_event).is_not_same(pen_event)
+ assert_str(wrench_event.get_target_name()).is_equal("r5_wrench")
+ assert_str(pen_event.get_target_name()).is_equal("r5_pen")
+ assert_int(wrench_event.get_num_statements_in_block()).is_equal(1)
+ assert_int(pen_event.get_num_statements_in_block()).is_equal(1)
+
+
+func test_dialog_option_scope_shadowing_restores_outer_local() -> void:
+ # A dialog option body introduces nested block scope. Assignments inside the
+ # option should see the inner shadowing variable, while execution after the
+ # dialog must still resolve the original outer local.
+ var outcome := await _interpret_fixture(
+ "dialog_scope_shadowing.esc",
+ [0]
+ )
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("inner:outer")
+
+
+func test_interrupting_background_channel_does_not_interrupt_front_channel() -> void:
+ # Background-channel interruption should only affect commands running on
+ # that channel. The front event must still complete normally while the
+ # background event should finish as interrupted once its in-flight command
+ # observes the interrupt.
+ var events = _load_fixture_events("channel_interrupt_isolation.esc")
+ assert_bool(events.has_event_with_target("front_event")).is_true()
+ assert_bool(events.has_event_with_target("background_event")).is_true()
+ escoria.globals_manager.set_global("channel_front_result", "start")
+ escoria.globals_manager.set_global("channel_background_result", "start")
+ escoria.globals_manager.set_global("channel_front_was_interrupted", false)
+ escoria.globals_manager.set_global("channel_background_was_interrupted", false)
+
+ var signal_results := {
+ "front": null,
+ "background": null,
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ if event_name == "front_event":
+ signal_results["front"] = [return_code, event_name]
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if event_name == "background_event" and finished_channel_name == "bg_test":
+ signal_results["background"] = [return_code, event_name, finished_channel_name]
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_event"))
+ escoria.event_manager.queue_background_event(
+ "bg_test",
+ events.get_event_with_target("background_event")
+ )
+
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.interrupt_channel("bg_test")
+
+ while signal_results["front"] == null or signal_results["background"] == null:
+ await escoria.get_tree().process_frame
+
+ var front_finished = signal_results["front"]
+ var background_finished = signal_results["background"]
+
+ assert_int(int(front_finished[0])).is_equal(ESCExecution.RC_OK)
+ assert_int(int(background_finished[0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(escoria.globals_manager.get_global("channel_front_result"))).is_equal("front-cmd-after")
+ assert_bool(escoria.globals_manager.get_global("channel_front_was_interrupted") == true).is_false()
+ assert_str(String(escoria.globals_manager.get_global("channel_background_result"))).is_equal("start")
+ assert_bool(escoria.globals_manager.get_global("channel_background_was_interrupted") == true).is_true()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_concurrent_channels_complete_without_interrupting_each_other() -> void:
+ # Two events on different channels should be able to run at the same time
+ # and still complete normally when no interrupt is issued.
+ var events = _load_fixture_events("channel_interrupt_isolation.esc")
+ assert_bool(events.has_event_with_target("front_event")).is_true()
+ assert_bool(events.has_event_with_target("background_event")).is_true()
+ escoria.globals_manager.set_global("channel_front_result", "start")
+ escoria.globals_manager.set_global("channel_background_result", "start")
+ escoria.globals_manager.set_global("channel_front_was_interrupted", false)
+ escoria.globals_manager.set_global("channel_background_was_interrupted", false)
+
+ var signal_results := {
+ "front": null,
+ "background": null,
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ if event_name == "front_event":
+ signal_results["front"] = [return_code, event_name]
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if event_name == "background_event" and finished_channel_name == "bg_test":
+ signal_results["background"] = [return_code, event_name, finished_channel_name]
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_event"))
+ escoria.event_manager.queue_background_event(
+ "bg_test",
+ events.get_event_with_target("background_event")
+ )
+
+ while signal_results["front"] == null or signal_results["background"] == null:
+ await escoria.get_tree().process_frame
+
+ var front_finished = signal_results["front"]
+ var background_finished = signal_results["background"]
+
+ assert_int(int(front_finished[0])).is_equal(ESCExecution.RC_OK)
+ assert_int(int(background_finished[0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(escoria.globals_manager.get_global("channel_front_result"))).is_equal("front-cmd-after")
+ assert_bool(escoria.globals_manager.get_global("channel_front_was_interrupted") == true).is_false()
+ assert_str(String(escoria.globals_manager.get_global("channel_background_result"))).is_equal("bg-cmd-after")
+ assert_bool(escoria.globals_manager.get_global("channel_background_was_interrupted") == true).is_false()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_interrupting_channel_clears_queued_successor_events() -> void:
+ # Interrupting a channel should stop the currently running event and purge
+ # later queued events on that same channel before they ever start running.
+ var events = _load_fixture_events("channel_interrupt_clears_queue.esc")
+ assert_bool(events.has_event_with_target("first")).is_true()
+ assert_bool(events.has_event_with_target("second")).is_true()
+ escoria.globals_manager.set_global("bg_queue_interrupted", false)
+
+ var background_finishes: Array = []
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ background_finishes.append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("first"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("second"))
+
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.interrupt_channel("bg_test")
+
+ while background_finishes.is_empty():
+ await escoria.get_tree().process_frame
+
+ assert_int(background_finishes.size()).is_equal(1)
+ assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(background_finishes[0][1])).is_equal("first")
+ assert_str(String(background_finishes[0][2])).is_equal("bg_test")
+ assert_str(String(escoria.globals_manager.get_global("bg_queue_result"))).is_equal("first")
+ assert_bool(escoria.globals_manager.get_global("bg_queue_interrupted") == true).is_true()
+
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_concurrent_channels_do_not_block_other_channel_queues() -> void:
+ # Each channel should serialize only its own queue. A front-channel event
+ # must complete independently while a background channel works through two
+ # queued events in order.
+ var events = _load_fixture_events("channel_queue_progress_isolated.esc")
+ assert_bool(events.has_event_with_target("front_event")).is_true()
+ assert_bool(events.has_event_with_target("background_first")).is_true()
+ assert_bool(events.has_event_with_target("background_second")).is_true()
+ escoria.globals_manager.set_global("isolated_front_result", "start")
+ escoria.globals_manager.set_global("isolated_background_result", "start")
+ escoria.globals_manager.set_global("isolated_front_interrupted", false)
+ escoria.globals_manager.set_global("isolated_background_interrupted", false)
+
+ var signal_results := {
+ "front": null,
+ "background_finishes": [],
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ if event_name == "front_event":
+ signal_results["front"] = [return_code, event_name]
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ signal_results["background_finishes"].append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_event"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_first"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_second"))
+
+ while signal_results["front"] == null or signal_results["background_finishes"].size() < 2:
+ await escoria.get_tree().process_frame
+
+ var front_finished = signal_results["front"]
+ var background_finishes: Array = signal_results["background_finishes"]
+
+ assert_int(int(front_finished[0])).is_equal(ESCExecution.RC_OK)
+ assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[0][1])).is_equal("background_first")
+ assert_int(int(background_finishes[1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[1][1])).is_equal("background_second")
+ assert_str(String(escoria.globals_manager.get_global("isolated_front_result"))).is_equal("front-cmd-after")
+ assert_bool(escoria.globals_manager.get_global("isolated_front_interrupted") == true).is_false()
+ assert_str(String(escoria.globals_manager.get_global("isolated_background_result"))).is_equal("first-cmd-after-first-second")
+ assert_bool(escoria.globals_manager.get_global("isolated_background_interrupted") == true).is_false()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_global_interrupt_stops_running_channels_and_clears_queues() -> void:
+ # A global interrupt should stop every currently running channel and purge
+ # any queued successor events before they start. This fixture queues one
+ # running and one queued event on both `_front` and `bg_test`.
+ var events = _load_fixture_events("global_interrupt_clears_all_channels.esc")
+ assert_bool(events.has_event_with_target("front_running")).is_true()
+ assert_bool(events.has_event_with_target("front_queued")).is_true()
+ assert_bool(events.has_event_with_target("background_running")).is_true()
+ assert_bool(events.has_event_with_target("background_queued")).is_true()
+ escoria.globals_manager.set_global("global_interrupt_front_result", "start")
+ escoria.globals_manager.set_global("global_interrupt_background_result", "start")
+ escoria.globals_manager.set_global("global_interrupt_front_was_interrupted", false)
+ escoria.globals_manager.set_global("global_interrupt_background_was_interrupted", false)
+
+ var signal_results := {
+ "front_finishes": [],
+ "background_finishes": [],
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ signal_results["front_finishes"].append([return_code, event_name])
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ signal_results["background_finishes"].append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_running"))
+ escoria.event_manager.queue_event(events.get_event_with_target("front_queued"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_running"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_queued"))
+
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.interrupt()
+
+ while signal_results["front_finishes"].is_empty() or signal_results["background_finishes"].is_empty():
+ await escoria.get_tree().process_frame
+
+ await escoria.get_tree().create_timer(0.05).timeout
+
+ assert_int(signal_results["front_finishes"].size()).is_equal(1)
+ assert_int(int(signal_results["front_finishes"][0][0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(signal_results["front_finishes"][0][1])).is_equal("front_running")
+ assert_int(signal_results["background_finishes"].size()).is_equal(1)
+ assert_int(int(signal_results["background_finishes"][0][0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(signal_results["background_finishes"][0][1])).is_equal("background_running")
+ assert_str(String(signal_results["background_finishes"][0][2])).is_equal("bg_test")
+ assert_str(String(escoria.globals_manager.get_global("global_interrupt_front_result"))).is_equal("start")
+ assert_bool(escoria.globals_manager.get_global("global_interrupt_front_was_interrupted") == true).is_true()
+ assert_str(String(escoria.globals_manager.get_global("global_interrupt_background_result"))).is_equal("start")
+ assert_bool(escoria.globals_manager.get_global("global_interrupt_background_was_interrupted") == true).is_true()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_queue_event_block_waits_for_correct_background_event_under_concurrency() -> void:
+ # Blocking queue_event waits must ignore unrelated background completions,
+ # including earlier events on the same channel, and return only when the
+ # specifically requested background event finishes.
+ var script_object := _compile_fixture_script("queue_event_block_waits_for_correct_background_event.esc")
+ assert_bool(script_object.has_event_with_target("background_first")).is_true()
+ assert_bool(script_object.has_event_with_target("background_target")).is_true()
+ assert_bool(script_object.has_event_with_target("front_event")).is_true()
+ escoria.globals_manager.set_global("block_wait_result", "start")
+ escoria.globals_manager.set_global("block_wait_front_result", "start")
+
+ escoria.event_manager.queue_background_event(
+ "bg_test",
+ script_object.get_event_with_target("background_first")
+ )
+ escoria.event_manager.queue_event(script_object.get_event_with_target("front_event"))
+
+ var block_rc: int = await escoria.event_manager.queue_event_from_esc(
+ script_object,
+ "background_target",
+ "bg_test",
+ true
+ )
+
+ assert_int(block_rc).is_equal(ESCExecution.RC_OK)
+ assert_str(String(escoria.globals_manager.get_global("block_wait_result"))).is_equal("first-target")
+ assert_str(String(escoria.globals_manager.get_global("block_wait_front_result"))).is_equal("front")
+
+
+func test_queue_event_block_waits_for_correct_front_event_under_concurrency() -> void:
+ # Blocking front queue_event waits must ignore both background completions
+ # and earlier foreground events, returning only when the specifically
+ # requested front event finishes.
+ var script_object := _compile_fixture_script("queue_event_block_waits_for_correct_front_event.esc")
+ assert_bool(script_object.has_event_with_target("front_first")).is_true()
+ assert_bool(script_object.has_event_with_target("front_target")).is_true()
+ assert_bool(script_object.has_event_with_target("background_event")).is_true()
+ escoria.globals_manager.set_global("front_block_wait_result", "start")
+ escoria.globals_manager.set_global("front_block_wait_background_result", "start")
+
+ escoria.event_manager.queue_event(script_object.get_event_with_target("front_first"))
+ escoria.event_manager.queue_background_event(
+ "bg_test",
+ script_object.get_event_with_target("background_event")
+ )
+
+ var block_rc: int = await escoria.event_manager.queue_event_from_esc(
+ script_object,
+ "front_target",
+ "_front",
+ true
+ )
+
+ assert_int(block_rc).is_equal(ESCExecution.RC_OK)
+ assert_str(String(escoria.globals_manager.get_global("front_block_wait_result"))).is_equal("first-target")
+ assert_str(String(escoria.globals_manager.get_global("front_block_wait_background_result"))).is_equal("background")
+
+
+func test_sequential_front_events_preserve_globals_between_runtime_interpreters() -> void:
+ # Each event-manager run currently observes globals written by earlier runs.
+ # The ownership refactor must preserve that user-visible behavior even though
+ # the underlying interpreter instances will stop sharing a singleton slot.
+ var events = _load_fixture_events("interpreter_runtime_state_persists_between_events.esc")
+ assert_bool(events.has_event_with_target("first")).is_true()
+ assert_bool(events.has_event_with_target("second")).is_true()
+
+ var front_finishes: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ front_finishes.append([return_code, event_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("first"))
+ while front_finishes.size() < 1:
+ await escoria.get_tree().process_frame
+
+ escoria.event_manager.queue_event(events.get_event_with_target("second"))
+ while front_finishes.size() < 2:
+ await escoria.get_tree().process_frame
+
+ assert_int(front_finishes.size()).is_equal(2)
+ assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(front_finishes[0][1])).is_equal("first")
+ assert_int(int(front_finishes[1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(front_finishes[1][1])).is_equal("second")
+ assert_str(String(escoria.globals_manager.get_global("interpreter_runtime_result"))).is_equal("first-second")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+
+
+func test_background_event_sees_globals_written_by_previous_front_event() -> void:
+ # Front and background channels each create their own runtime interpreter.
+ # A background event queued after a front event must still observe globals
+ # written by that earlier front-channel execution.
+ var events = _load_fixture_events("interpreter_runtime_state_persists_between_events.esc")
+ assert_bool(events.has_event_with_target("background_seed")).is_true()
+ assert_bool(events.has_event_with_target("background_after")).is_true()
+
+ var front_finishes: Array = []
+ var background_finishes: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ front_finishes.append([return_code, event_name])
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ background_finishes.append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("background_seed"))
+ while front_finishes.size() < 1:
+ await escoria.get_tree().process_frame
+
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_after"))
+ while background_finishes.size() < 1:
+ await escoria.get_tree().process_frame
+
+ assert_int(front_finishes.size()).is_equal(1)
+ assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(front_finishes[0][1])).is_equal("background_seed")
+ assert_int(background_finishes.size()).is_equal(1)
+ assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[0][1])).is_equal("background_after")
+ assert_str(String(background_finishes[0][2])).is_equal("bg_test")
+ assert_str(String(escoria.globals_manager.get_global("interpreter_runtime_background_result"))).is_equal("first-background")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_running_interpreter_sees_new_global_defined_by_other_channel() -> void:
+ # A runtime interpreter that is already running must observe globals declared
+ # by a concurrent channel after it has started. Otherwise overlapping events
+ # can regress from shared-global behavior to "Undefined variable" failures.
+ var events = _load_fixture_events("concurrent_new_global_visibility.esc")
+ assert_bool(events.has_event_with_target("front_waits_for_new_global")).is_true()
+ assert_bool(events.has_event_with_target("background_declares_new_global")).is_true()
+
+ var front_finishes: Array = []
+ var background_finishes: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ front_finishes.append([return_code, event_name])
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ background_finishes.append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_waits_for_new_global"))
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_declares_new_global"))
+
+ while front_finishes.is_empty() or background_finishes.is_empty():
+ await escoria.get_tree().process_frame
+
+ assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(str(front_finishes[0][1])).is_equal("front_waits_for_new_global")
+ assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(str(background_finishes[0][1])).is_equal("background_declares_new_global")
+ assert_str(str(escoria.globals_manager.get_global("concurrent_visibility_result"))).is_equal("from-background")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_queue_background_event_allows_consecutive_duplicate_queue_entries() -> void:
+ # Queueing the same background event twice in a row should be allowed on a
+ # background channel. In practice these queues are populated via ASHES
+ # commands, so the second request should remain queued behind the first run.
+ var events = _load_fixture_events("background_duplicate_suppression.esc")
+ assert_bool(events.has_event_with_target("repeat_event")).is_true()
+ escoria.globals_manager.set_global("background_duplicate_count", 0)
+ escoria.globals_manager.set_global("background_duplicate_last", "start")
+
+ var background_finishes: Array = []
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ background_finishes.append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("repeat_event"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("repeat_event"))
+
+ while background_finishes.size() < 2:
+ await escoria.get_tree().process_frame
+
+ assert_int(background_finishes.size()).is_equal(2)
+ assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[0][1])).is_equal("repeat_event")
+ assert_str(String(background_finishes[0][2])).is_equal("bg_test")
+ assert_int(int(background_finishes[1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[1][1])).is_equal("repeat_event")
+ assert_str(String(background_finishes[1][2])).is_equal("bg_test")
+ assert_int(int(escoria.globals_manager.get_global("background_duplicate_count"))).is_equal(2)
+ assert_str(String(escoria.globals_manager.get_global("background_duplicate_last"))).is_equal("tick")
+
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_queue_background_event_allows_same_event_after_current_run_starts() -> void:
+ # Once the first background event is already running, queueing the same event
+ # again on that channel should queue a second run behind it instead of being
+ # suppressed as a duplicate.
+ var events = _load_fixture_events("background_duplicate_suppression.esc")
+ assert_bool(events.has_event_with_target("repeat_event")).is_true()
+ escoria.globals_manager.set_global("background_duplicate_count", 0)
+ escoria.globals_manager.set_global("background_duplicate_last", "start")
+
+ var background_finishes: Array = []
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ background_finishes.append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("repeat_event"))
+ await escoria.get_tree().create_timer(0.01).timeout
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("repeat_event"))
+
+ while background_finishes.size() < 2:
+ await escoria.get_tree().process_frame
+
+ assert_int(background_finishes.size()).is_equal(2)
+ assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[0][1])).is_equal("repeat_event")
+ assert_int(int(background_finishes[1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[1][1])).is_equal("repeat_event")
+ assert_int(int(escoria.globals_manager.get_global("background_duplicate_count"))).is_equal(2)
+ assert_str(String(escoria.globals_manager.get_global("background_duplicate_last"))).is_equal("tick")
+
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_global_interrupt_preserves_exception_events() -> void:
+ # `interrupt(exceptions=...)` should leave only the named running and queued
+ # events alive. Here the front running event and one queued background event
+ # are excepted, while the other front/background events must be cleared.
+ var events = _load_fixture_events("global_interrupt_preserves_exceptions.esc")
+ assert_bool(events.has_event_with_target("front_keep")).is_true()
+ assert_bool(events.has_event_with_target("front_drop")).is_true()
+ assert_bool(events.has_event_with_target("background_drop")).is_true()
+ assert_bool(events.has_event_with_target("background_keep")).is_true()
+ escoria.globals_manager.set_global("interrupt_except_front_result", "start")
+ escoria.globals_manager.set_global("interrupt_except_front_interrupted", false)
+ escoria.globals_manager.set_global("interrupt_except_background_result", "start")
+ escoria.globals_manager.set_global("interrupt_except_background_interrupted", false)
+ escoria.globals_manager.set_global("interrupt_except_background_keep_result", "start")
+
+ var signal_results := {
+ "front_finishes": [],
+ "background_finishes": [],
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ signal_results["front_finishes"].append([return_code, event_name])
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ signal_results["background_finishes"].append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_keep"))
+ escoria.event_manager.queue_event(events.get_event_with_target("front_drop"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_drop"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_keep"))
+
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.interrupt(PackedStringArray(["front_keep", "background_keep"]))
+
+ while signal_results["front_finishes"].is_empty() or signal_results["background_finishes"].size() < 2:
+ await escoria.get_tree().process_frame
+
+ assert_int(signal_results["front_finishes"].size()).is_equal(1)
+ assert_int(int(signal_results["front_finishes"][0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(signal_results["front_finishes"][0][1])).is_equal("front_keep")
+ assert_int(signal_results["background_finishes"].size()).is_equal(2)
+ assert_int(int(signal_results["background_finishes"][0][0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(signal_results["background_finishes"][0][1])).is_equal("background_drop")
+ assert_int(int(signal_results["background_finishes"][1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(signal_results["background_finishes"][1][1])).is_equal("background_keep")
+ assert_str(String(escoria.globals_manager.get_global("interrupt_except_front_result"))).is_equal("front-cmd-after")
+ assert_bool(escoria.globals_manager.get_global("interrupt_except_front_interrupted") == true).is_false()
+ assert_str(String(escoria.globals_manager.get_global("interrupt_except_background_result"))).is_equal("start")
+ assert_bool(escoria.globals_manager.get_global("interrupt_except_background_interrupted") == true).is_true()
+ assert_str(String(escoria.globals_manager.get_global("interrupt_except_background_keep_result"))).is_equal("bg-keep-after")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_scheduled_front_event_runs_after_current_front_event_while_background_is_busy() -> void:
+ # Scheduled events always feed the `_front` queue. They should still dispatch
+ # while a background channel is busy, but must wait for the current front
+ # event to finish before the scheduled front event runs.
+ var events = _load_fixture_events("scheduled_front_event_with_busy_background.esc")
+ assert_bool(events.has_event_with_target("front_running")).is_true()
+ assert_bool(events.has_event_with_target("scheduled_front")).is_true()
+ assert_bool(events.has_event_with_target("background_running")).is_true()
+ escoria.globals_manager.set_global("scheduled_front_result", "start")
+ escoria.globals_manager.set_global("scheduled_front_interrupted", false)
+ escoria.globals_manager.set_global("scheduled_background_result", "start")
+ escoria.globals_manager.set_global("scheduled_background_interrupted", false)
+
+ var signal_results := {
+ "front_finishes": [],
+ "background_finishes": [],
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ signal_results["front_finishes"].append([return_code, event_name])
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ signal_results["background_finishes"].append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_running"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_running"))
+ escoria.event_manager.schedule_event(events.get_event_with_target("scheduled_front"), 0.05, "")
+
+ while signal_results["front_finishes"].size() < 2 or signal_results["background_finishes"].is_empty():
+ await escoria.get_tree().process_frame
+
+ assert_int(signal_results["front_finishes"].size()).is_equal(2)
+ assert_int(int(signal_results["front_finishes"][0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(signal_results["front_finishes"][0][1])).is_equal("front_running")
+ assert_int(int(signal_results["front_finishes"][1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(signal_results["front_finishes"][1][1])).is_equal("scheduled_front")
+ assert_int(signal_results["background_finishes"].size()).is_equal(1)
+ assert_int(int(signal_results["background_finishes"][0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(signal_results["background_finishes"][0][1])).is_equal("background_running")
+ assert_str(String(escoria.globals_manager.get_global("scheduled_front_result"))).is_equal("front-running-after-scheduled")
+ assert_bool(escoria.globals_manager.get_global("scheduled_front_interrupted") == true).is_false()
+ assert_str(String(escoria.globals_manager.get_global("scheduled_background_result"))).is_equal("background-running-after")
+ assert_bool(escoria.globals_manager.get_global("scheduled_background_interrupted") == true).is_false()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_global_interrupt_preserves_scheduled_events_before_dispatch() -> void:
+ # Global interrupt should stop the currently running event, but it should
+ # not clear scheduled events that have not yet been promoted into the front
+ # queue. The scheduled front event should therefore still dispatch and
+ # complete normally after the interrupted event finishes.
+ var events = _load_fixture_events("scheduled_event_interrupted_before_dispatch.esc")
+ assert_bool(events.has_event_with_target("front_running")).is_true()
+ assert_bool(events.has_event_with_target("scheduled_front")).is_true()
+ escoria.globals_manager.set_global("scheduled_interrupt_result", "start")
+ escoria.globals_manager.set_global("scheduled_interrupt_was_interrupted", false)
+
+ var front_finishes: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ front_finishes.append([return_code, event_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_running"))
+ escoria.event_manager.schedule_event(events.get_event_with_target("scheduled_front"), 0.2, "")
+
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.interrupt()
+
+ while front_finishes.is_empty():
+ await escoria.get_tree().process_frame
+
+ await escoria.get_tree().create_timer(0.25).timeout
+
+ assert_int(front_finishes.size()).is_equal(2)
+ assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(front_finishes[0][1])).is_equal("front_running")
+ assert_int(int(front_finishes[1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(front_finishes[1][1])).is_equal("scheduled_front")
+ assert_str(String(escoria.globals_manager.get_global("scheduled_interrupt_result"))).is_equal("start-scheduled")
+ assert_bool(escoria.globals_manager.get_global("scheduled_interrupt_was_interrupted") == true).is_true()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+
+
+func test_clear_event_queue_preserves_scheduled_events() -> void:
+ # Clearing the event queue should only remove events that are already queued
+ # for execution. Scheduled events live in a separate list until `_process()`
+ # promotes them into the front queue, so they should still dispatch after
+ # `clear_event_queue()` is called.
+ var events = _load_fixture_events("scheduled_event_interrupted_before_dispatch.esc")
+ assert_bool(events.has_event_with_target("scheduled_front")).is_true()
+ escoria.globals_manager.set_global("scheduled_interrupt_result", "start")
+
+ var front_finishes: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ front_finishes.append([return_code, event_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+
+ escoria.event_manager.schedule_event(events.get_event_with_target("scheduled_front"), 0.05, "")
+ escoria.event_manager.clear_event_queue()
+
+ while front_finishes.is_empty():
+ await escoria.get_tree().process_frame
+
+ assert_int(front_finishes.size()).is_equal(1)
+ assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(front_finishes[0][1])).is_equal("scheduled_front")
+ assert_str(String(escoria.globals_manager.get_global("scheduled_interrupt_result"))).is_equal("start-scheduled")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+
+
+func test_interrupt_channel_does_not_affect_still_scheduled_front_events() -> void:
+ # A scheduled front event should be unaffected by `interrupt_channel()` for a
+ # background channel while it is still waiting in `scheduled_events`. Once it
+ # is later promoted into `_front`, it should run like any other front-queue
+ # event.
+ var events = _load_fixture_events("scheduled_front_event_with_busy_background.esc")
+ assert_bool(events.has_event_with_target("scheduled_front")).is_true()
+ assert_bool(events.has_event_with_target("background_running")).is_true()
+ escoria.globals_manager.set_global("scheduled_front_result", "start")
+ escoria.globals_manager.set_global("scheduled_front_interrupted", false)
+ escoria.globals_manager.set_global("scheduled_background_result", "start")
+ escoria.globals_manager.set_global("scheduled_background_interrupted", false)
+
+ var signal_results := {
+ "front_finishes": [],
+ "background_finishes": [],
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ signal_results["front_finishes"].append([return_code, event_name])
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ signal_results["background_finishes"].append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_running"))
+ escoria.event_manager.schedule_event(events.get_event_with_target("scheduled_front"), 0.2, "")
+
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.interrupt_channel("bg_test")
+
+ while signal_results["front_finishes"].is_empty() or signal_results["background_finishes"].is_empty():
+ await escoria.get_tree().process_frame
+
+ assert_int(signal_results["background_finishes"].size()).is_equal(1)
+ assert_int(int(signal_results["background_finishes"][0][0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(signal_results["background_finishes"][0][1])).is_equal("background_running")
+ assert_int(signal_results["front_finishes"].size()).is_equal(1)
+ assert_int(int(signal_results["front_finishes"][0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(signal_results["front_finishes"][0][1])).is_equal("scheduled_front")
+ assert_str(String(escoria.globals_manager.get_global("scheduled_front_result"))).is_equal("start-scheduled")
+ assert_bool(escoria.globals_manager.get_global("scheduled_front_interrupted") == true).is_false()
+ assert_str(String(escoria.globals_manager.get_global("scheduled_background_result"))).is_equal("start")
+ assert_bool(escoria.globals_manager.get_global("scheduled_background_interrupted") == true).is_true()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_immediate_command_preserves_statement_ordering() -> void:
+ # A synchronous command should complete before the next statement runs. The
+ # trailing assignment observes the command's mutation and appends to it.
+ var outcome := await _interpret_fixture("immediate_command_ordering.esc")
+ var globals: Dictionary = outcome.globals
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("cmd-after")
+
+
+func test_delayed_command_preserves_statement_ordering() -> void:
+ # An asynchronous command must also block statement sequencing. If the
+ # interpreter failed to await it, the trailing assignment would append to the
+ # pre-command value instead of the command-written one.
+ var outcome := await _interpret_fixture("delayed_command_ordering.esc")
+ var globals: Dictionary = outcome.globals
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("cmd-after")
+
+
+func test_set_animations_updates_persisted_player_resource_on_repeat_calls() -> void:
+ # Room changes restore player animations from the reserved
+ # `ANIMATION_RESOURCES` global. Repeated `set_animations()` calls must update
+ # the stored path each time, otherwise the next room load re-applies a stale
+ # costume even if the live player was changed back already.
+ var previous_animations = escoria.globals_manager.get_global(
+ escoria.room_manager.GLOBAL_ANIMATION_RESOURCES
+ )
+ var room := ESCRoom.new()
+ room.global_id = "test_room_issue_1226"
+ escoria.object_manager.set_current_room(room)
+
+ var player := MARK_SCENE.instantiate() as ESCPlayer
+ player.global_id = "player"
+ escoria.object_manager.register_object(
+ ESCObject.new(player.global_id, player),
+ room,
+ true,
+ false
+ )
+ escoria.globals_manager.set_global(
+ escoria.room_manager.GLOBAL_ANIMATION_RESOURCES,
+ {},
+ true
+ )
+
+ var command := SetAnimationsCommand.new()
+ var jester_animations := "res://game/characters/mark/mark_animations_jester.tres"
+ var default_animations := "res://game/characters/mark/mark_animations.tres"
+
+ assert_int(command.run([player.global_id, jester_animations])).is_equal(ESCExecution.RC_OK)
+ assert_int(command.run([player.global_id, default_animations])).is_equal(ESCExecution.RC_OK)
+
+ var animations: Dictionary = escoria.globals_manager.get_global(
+ escoria.room_manager.GLOBAL_ANIMATION_RESOURCES
+ )
+ assert_bool(animations.has(player.global_id)).is_true()
+ assert_str(String(animations[player.global_id])).is_equal(default_animations)
+
+ var room_key := ESCRoomObjectsKey.new()
+ room_key.room_global_id = room.global_id
+ room_key.room_instance_id = room.get_instance_id()
+ escoria.object_manager.unregister_object_by_global_id(player.global_id, room_key)
+ escoria.globals_manager.set_global(
+ escoria.room_manager.GLOBAL_ANIMATION_RESOURCES,
+ previous_animations if previous_animations != null else {},
+ true
+ )
+ player.free()
+ room.free()
+
+
+func test_set_speed_rejects_negative_values_but_accepts_zero() -> void:
+ var room := ESCRoom.new()
+ room.global_id = "test_room_set_speed_contract"
+ escoria.object_manager.set_current_room(room)
+
+ var player := MARK_SCENE.instantiate() as ESCPlayer
+ player.global_id = "player"
+ escoria.object_manager.register_object(
+ ESCObject.new(player.global_id, player),
+ room,
+ true,
+ false
+ )
+
+ var command := SetSpeedCommand.new()
+ var initial_speed := player.speed
+
+ assert_bool(command.validate([player.global_id, -1])).is_false()
+ assert_int(player.speed).is_equal(initial_speed)
+
+ assert_bool(command.validate([player.global_id, 0])).is_true()
+ assert_int(command.run([player.global_id, 0])).is_equal(ESCExecution.RC_OK)
+ assert_int(player.speed).is_equal(0)
+
+ var room_key := ESCRoomObjectsKey.new()
+ room_key.room_global_id = room.global_id
+ room_key.room_instance_id = room.get_instance_id()
+ escoria.object_manager.unregister_object_by_global_id(player.global_id, room_key)
+ player.free()
+ room.free()
+
+
+func test_trigger_actions_select_targeted_events_for_activating_object() -> void:
+ var script_object := _compile_fixture_script("trigger_targeted_events_use_object_id.esc")
+ var room := ESCRoom.new()
+ room.global_id = "test_trigger_targeted_events_room"
+ escoria.object_manager.set_current_room(room)
+
+ var trigger_node := Node2D.new()
+ var trigger_object := ESCObject.new("trigger_under_test", trigger_node)
+ trigger_object.events = script_object.events
+ escoria.object_manager.register_object(trigger_object, room, true, false)
+
+ escoria.globals_manager.set_global("trigger_in_result", "unset", true)
+ escoria.globals_manager.set_global("trigger_out_result", "unset", true)
+
+ var finished_events: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ if return_code == ESCExecution.RC_OK:
+ finished_events.append(event_name)
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+
+ escoria.action_manager.do(
+ escoria.action_manager.ACTION.TRIGGER_IN,
+ [trigger_object.global_id, "player", "trigger_in"]
+ )
+
+ if not await _wait_for_finished_events(finished_events, 1, "targeted trigger_in"):
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ _cleanup_registered_object(trigger_object, trigger_node, room)
+ return
+
+ assert_str(String(escoria.globals_manager.get_global("trigger_in_result"))).is_equal(
+ "player-in"
+ )
+ assert_str(String(finished_events[0])).is_equal("trigger_in")
+
+ escoria.action_manager.do(
+ escoria.action_manager.ACTION.TRIGGER_OUT,
+ [trigger_object.global_id, "player", "trigger_out"]
+ )
+
+ if not await _wait_for_finished_events(finished_events, 2, "targeted trigger_out"):
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ _cleanup_registered_object(trigger_object, trigger_node, room)
+ return
+
+ assert_str(String(escoria.globals_manager.get_global("trigger_out_result"))).is_equal(
+ "player-out"
+ )
+ assert_str(String(finished_events[1])).is_equal("trigger_out")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+
+ _cleanup_registered_object(trigger_object, trigger_node, room)
+
+
+func test_trigger_actions_fall_back_to_targetless_events() -> void:
+ var script_object := _compile_fixture_script("trigger_targeted_events_use_object_id.esc")
+ var room := ESCRoom.new()
+ room.global_id = "test_trigger_targetless_events_room"
+ escoria.object_manager.set_current_room(room)
+
+ var trigger_node := Node2D.new()
+ var trigger_object := ESCObject.new("trigger_under_test", trigger_node)
+ trigger_object.events = script_object.events
+ escoria.object_manager.register_object(trigger_object, room, true, false)
+
+ escoria.globals_manager.set_global("trigger_in_result", "unset", true)
+ escoria.globals_manager.set_global("trigger_out_result", "unset", true)
+
+ var finished_events: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ if return_code == ESCExecution.RC_OK:
+ finished_events.append(event_name)
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+
+ escoria.action_manager.do(
+ escoria.action_manager.ACTION.TRIGGER_IN,
+ [trigger_object.global_id, "unexpected_actor", "trigger_in"]
+ )
+
+ if not await _wait_for_finished_events(finished_events, 1, "targetless trigger_in fallback"):
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ _cleanup_registered_object(trigger_object, trigger_node, room)
+ return
+
+ assert_str(String(escoria.globals_manager.get_global("trigger_in_result"))).is_equal(
+ "fallback-in"
+ )
+ assert_str(String(finished_events[0])).is_equal("trigger_in")
+
+ escoria.action_manager.do(
+ escoria.action_manager.ACTION.TRIGGER_OUT,
+ [trigger_object.global_id, "unexpected_actor", "trigger_out"]
+ )
+
+ if not await _wait_for_finished_events(finished_events, 2, "targetless trigger_out fallback"):
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ _cleanup_registered_object(trigger_object, trigger_node, room)
+ return
+
+ assert_str(String(escoria.globals_manager.get_global("trigger_out_result"))).is_equal(
+ "fallback-out"
+ )
+ assert_str(String(finished_events[1])).is_equal("trigger_out")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+
+ _cleanup_registered_object(trigger_object, trigger_node, room)
+
+
+func _cleanup_registered_object(trigger_object: ESCObject, trigger_node: Node, room: ESCRoom) -> void:
+ var room_key := ESCRoomObjectsKey.new()
+ room_key.room_global_id = room.global_id
+ room_key.room_instance_id = room.get_instance_id()
+ escoria.object_manager.unregister_object_by_global_id(trigger_object.global_id, room_key)
+ trigger_node.free()
+ room.free()
+
+
+func _wait_for_finished_events(
+ finished_events: Array,
+ expected_size: int,
+ context: String
+) -> bool:
+ for _frame in range(120):
+ if finished_events.size() >= expected_size:
+ return true
+
+ await escoria.get_tree().process_frame
+
+ fail(
+ "Timed out waiting for %s; expected %d finished event(s), got %d." % [
+ context,
+ expected_size,
+ finished_events.size()
+ ]
+ )
+ return false
+
+
+func _interpret_fixture(name: String, dialog_choices: Array = []) -> Dictionary:
+ # Shared interpreter harness for fixture-based tests. This intentionally
+ # runs the full scan -> parse -> resolve -> interpret pipeline so the tests
+ # cover the real runtime path rather than isolated visitor calls.
+ var source := _load_fixture(name)
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path(name))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+ var dialog_player = null
+
+ if not dialog_choices.is_empty():
+ # Install a temporary deterministic dialog player only for tests that
+ # need scripted choices, then clean it up afterward to avoid orphan nodes.
+ dialog_player = DialogPlayerDouble.new(dialog_choices)
+ escoria.set("dialog_player", dialog_player)
+
+ await interpreter.interpret(statements)
+
+ var globals := interpreter.get_global_values().duplicate(true)
+ var dialog_choices_consumed := 0
+ var remaining_dialog_choices := 0
+
+ if dialog_player:
+ dialog_choices_consumed = dialog_player.get_choices_consumed()
+ remaining_dialog_choices = dialog_player.get_remaining_choices()
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+ return {
+ "globals": globals,
+ "dialog_choices_consumed": dialog_choices_consumed,
+ "remaining_dialog_choices": remaining_dialog_choices,
+ }
+
+
+func test_dialog_start_args_are_passed_to_runtime_dialog() -> void:
+ var script_object := _compile_fixture_script("dialog_start_args_reach_runtime.esc")
+ var dialog_player := DialogPlayerDouble.new([1])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(1)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_str(started_dialogs[0]["avatar"]).is_equal("res://game/dialog_avatars/player.tres")
+ assert_int(started_dialogs[0]["timeout"]).is_equal(5)
+ assert_int(started_dialogs[0]["timeout_option"]).is_equal(2)
+ assert_int(started_dialogs[0]["authored_options_count"]).is_equal(2)
+ assert_int(started_dialogs[0]["options_size"]).is_equal(2)
+ assert_str(started_dialogs[0]["visible_timeout_option"]).is_equal("Second option")
+ assert_array(started_dialogs[0]["option_source_indexes"]).contains_exactly([1, 2])
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_timeout_option_uses_authored_index_after_filtering() -> void:
+ var script_object := _compile_fixture_script("dialog_timeout_visible_authored_default_after_filtering.esc")
+ var dialog_player := DialogPlayerDouble.new([1])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(1)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_int(started_dialogs[0]["timeout"]).is_equal(5)
+ assert_int(started_dialogs[0]["timeout_option"]).is_equal(3)
+ assert_int(started_dialogs[0]["authored_options_count"]).is_equal(3)
+ assert_int(started_dialogs[0]["options_size"]).is_equal(2)
+ assert_array(started_dialogs[0]["option_source_indexes"]).contains_exactly([2, 3])
+ assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["Second option", "Third option"])
+ assert_str(started_dialogs[0]["visible_timeout_option"]).is_equal("Third option")
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_timeout_option_hidden_after_filtering_is_not_available() -> void:
+ var script_object := _compile_fixture_script("dialog_timeout_hidden_authored_default_after_filtering.esc")
+ var dialog_player := DialogPlayerDouble.new([0])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(1)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_int(started_dialogs[0]["timeout"]).is_equal(5)
+ assert_int(started_dialogs[0]["timeout_option"]).is_equal(1)
+ assert_int(started_dialogs[0]["authored_options_count"]).is_equal(3)
+ assert_int(started_dialogs[0]["options_size"]).is_equal(2)
+ assert_array(started_dialogs[0]["option_source_indexes"]).contains_exactly([2, 3])
+ assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["Second option", "Third option"])
+ assert_object(started_dialogs[0]["visible_timeout_option"]).is_null()
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_simple_chooser_does_not_start_timer_when_timeout_default_is_hidden() -> void:
+ var dialog := ESCDialog.new()
+ dialog.timeout = 5
+ dialog.timeout_option = 1
+ dialog.authored_options_count = 2
+ dialog.options = [
+ _make_dialog_option("Visible option", 2),
+ ]
+
+ var chooser = SIMPLE_CHOOSER_SCENE.instantiate()
+ add_child(chooser)
+ chooser.set_dialog(dialog)
+ chooser.show_chooser()
+
+ assert_bool(chooser.get_node("Timer").is_stopped()).is_true()
+ assert_float(chooser.get_node("TimerProgress").value).is_equal(0.0)
+
+ chooser.queue_free()
+
+
+func test_simple_chooser_starts_timer_when_timeout_default_is_visible() -> void:
+ var dialog := ESCDialog.new()
+ dialog.timeout = 5
+ dialog.timeout_option = 2
+ dialog.authored_options_count = 2
+ dialog.options = [
+ _make_dialog_option("First option", 1),
+ _make_dialog_option("Second option", 2),
+ ]
+
+ var chooser = SIMPLE_CHOOSER_SCENE.instantiate()
+ add_child(chooser)
+ chooser.set_dialog(dialog)
+ chooser.show_chooser()
+
+ assert_bool(chooser.get_node("Timer").is_stopped()).is_false()
+
+ chooser.queue_free()
+
+
+func test_dialog_start_invalid_avatar_type_returns_error() -> void:
+ var script_object := _compile_fixture_script("dialog_start_invalid_avatar_type.esc")
+ var dialog_player := DialogPlayerDouble.new([])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(0)
+ assert_int(dialog_player.get_dialogs_started().size()).is_equal(0)
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_start_invalid_timeout_type_returns_error() -> void:
+ var script_object := _compile_fixture_script("dialog_start_invalid_timeout_type.esc")
+ var dialog_player := DialogPlayerDouble.new([])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(0)
+ assert_int(dialog_player.get_dialogs_started().size()).is_equal(0)
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_option_translation_key_is_passed_to_runtime_dialog() -> void:
+ var script_object := _compile_fixture_script("dialog_option_translation_key.esc")
+ var dialog_player := DialogPlayerDouble.new([0])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(1)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_array(started_dialogs[0]["option_keys"]).contains_exactly(["UNCLE_SVEN", ""])
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_start_invalid_timeout_option_type_returns_error() -> void:
+ var script_object := _compile_fixture_script("dialog_start_invalid_timeout_option_type.esc")
+ var dialog_player := DialogPlayerDouble.new([])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(0)
+ assert_int(dialog_player.get_dialogs_started().size()).is_equal(0)
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_start_invalid_timeout_option_negative_returns_error() -> void:
+ var script_object := _compile_fixture_script("dialog_start_invalid_timeout_option_negative.esc")
+ var dialog_player := DialogPlayerDouble.new([])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(0)
+ assert_int(dialog_player.get_dialogs_started().size()).is_equal(0)
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_option_translation_key_beats_legacy_prefix_fallback() -> void:
+ var script_object := _compile_fixture_script("dialog_option_translation_key_beats_legacy_prefix.esc")
+ var dialog_player := DialogPlayerDouble.new([0])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_array(started_dialogs[0]["option_keys"]).contains_exactly(["OUTER_KEY"])
+ assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["INNER_KEY:Fallback text"])
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_option_translation_key_with_condition_survives_runtime_filtering() -> void:
+ var script_object := _compile_fixture_script("dialog_option_translation_key_with_condition.esc")
+ var dialog_player := DialogPlayerDouble.new([0])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_int(started_dialogs[0]["options_size"]).is_equal(1)
+ assert_array(started_dialogs[0]["option_keys"]).contains_exactly(["CONDITIONAL_KEY"])
+ assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["Conditional option"])
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_option_translation_key_with_expression_preserves_key_and_value() -> void:
+ var script_object := _compile_fixture_script("dialog_option_translation_key_with_expression.esc")
+ var dialog_player := DialogPlayerDouble.new([0])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_array(started_dialogs[0]["option_keys"]).contains_exactly(["DYNAMIC_KEY"])
+ assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["Dynamic option"])
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func _cleanup_dialog_test(interpreter: ESCInterpreter, dialog_player: DialogPlayerDouble) -> void:
+ interpreter.cleanup()
+
+ if dialog_player:
+ escoria.set("dialog_player", null)
+ dialog_player.queue_free()
+
+
+func test_compiler_rejects_exact_duplicate_event_targets() -> void:
+ var source := _load_fixture("duplicate_exact_target_event_overwrites.esc")
+ var compiler := ESCCompiler.new()
+ var script_object := compiler.compile(source)
+
+ assert_bool(compiler.had_error).is_true()
+ assert_int(script_object.events.events.size()).is_equal(1)
+ assert_object(script_object.get_event_with_target("use", "wrench")).is_not_null()
+
+
+func _load_fixture(name: String) -> String:
+ return FileAccess.get_file_as_string(_fixture_path(name))
+
+
+func _load_fixture_events(name: String):
+ var script_object := _compile_fixture_script(name)
+ return script_object.events
+
+
+func _compile_fixture_script(name: String) -> ESCScript:
+ var source := _load_fixture(name)
+ var compiler := ESCCompiler.new()
+ var script_object := compiler.compile(source, _fixture_path(name))
+ assert_bool(compiler.had_error).is_false()
+ return script_object
+
+
+func _fixture_path(name: String) -> String:
+ return "res://addons/escoria-core/testing/ashes/interpreter/fixtures/%s" % name
+
+
+func _make_dialog_option(text: String, source_option_index: int) -> ESCDialogOption:
+ var option := ESCDialogOption.new()
+ option.option = text
+ option.source_option_index = source_option_index
+ option.set_is_valid(true)
+ return option
diff --git a/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd.uid b/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd.uid
new file mode 100644
index 0000000..f4c0246
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd.uid
@@ -0,0 +1 @@
+uid://cpogk3hmwoaom
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc
new file mode 100644
index 0000000..6ab1752
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc
@@ -0,0 +1,3 @@
+:repeat_event
+ test_delayed_set_global("background_duplicate_last", "tick", 0.05)
+ inc_global("background_duplicate_count")
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc.uid
new file mode 100644
index 0000000..eeed814
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc.uid
@@ -0,0 +1 @@
+uid://c5o8v1l4n3xmd
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc
new file mode 100644
index 0000000..e4c2d34
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc
@@ -0,0 +1,8 @@
+:test
+ global result = 0
+ var x = 1
+ while true:
+ if true:
+ var x = 2
+ break
+ result = x
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc.uid
new file mode 100644
index 0000000..1060cfd
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc.uid
@@ -0,0 +1 @@
+uid://ccf4pbps7lq2k
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc
new file mode 100644
index 0000000..cb170eb
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc
@@ -0,0 +1,14 @@
+:test
+ global result = "start"
+ ?!
+ - "Outer"
+ ?!
+ - "Middle"
+ ?!
+ - "Break out"
+ break 2
+ result = "after_inner"
+ result = "after_middle"
+ - "Exit outer"
+ break
+ result = result + "-done"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc.uid
new file mode 100644
index 0000000..31dc1f6
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc.uid
@@ -0,0 +1 @@
+uid://dorj4y3h76w2j
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc
new file mode 100644
index 0000000..4e9f649
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc
@@ -0,0 +1,7 @@
+:first
+ global bg_queue_result = "first"
+ test_interruptible_set_global("bg_queue_result", "running", "bg_queue_interrupted", 0.2)
+ bg_queue_result = bg_queue_result + "-after"
+
+:second
+ bg_queue_result = "second"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc.uid
new file mode 100644
index 0000000..45a95b6
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc.uid
@@ -0,0 +1 @@
+uid://dp5nx4h5bqgu1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc
new file mode 100644
index 0000000..e3130d4
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc
@@ -0,0 +1,9 @@
+:front_event
+ global channel_front_result = "start"
+ test_interruptible_set_global("channel_front_result", "front-cmd", "channel_front_was_interrupted", 0.2)
+ channel_front_result = channel_front_result + "-after"
+
+:background_event
+ global channel_background_result = "start"
+ test_interruptible_set_global("channel_background_result", "bg-cmd", "channel_background_was_interrupted", 0.2)
+ channel_background_result = channel_background_result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc.uid
new file mode 100644
index 0000000..5e3ad25
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc.uid
@@ -0,0 +1 @@
+uid://cx76t0u0r5f4f
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc
new file mode 100644
index 0000000..31098f8
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc
@@ -0,0 +1,12 @@
+:front_event
+ global isolated_front_result = "start"
+ test_interruptible_set_global("isolated_front_result", "front-cmd", "isolated_front_interrupted", 0.2)
+ isolated_front_result = isolated_front_result + "-after"
+
+:background_first
+ global isolated_background_result = "start"
+ test_interruptible_set_global("isolated_background_result", "first-cmd", "isolated_background_interrupted", 0.2)
+ isolated_background_result = isolated_background_result + "-after-first"
+
+:background_second
+ isolated_background_result = isolated_background_result + "-second"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc.uid
new file mode 100644
index 0000000..7961883
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc.uid
@@ -0,0 +1 @@
+uid://dzq6qk1ufhcv0
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc
new file mode 100644
index 0000000..3df3b97
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc
@@ -0,0 +1,7 @@
+:front_waits_for_new_global
+ global concurrent_visibility_result = "start"
+ test_delayed_set_global("concurrent_visibility_progress", "waiting", 0.2)
+ concurrent_visibility_result = shared_runtime_global
+
+:background_declares_new_global
+ global shared_runtime_global = "from-background"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc.uid
new file mode 100644
index 0000000..f19670e
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc.uid
@@ -0,0 +1 @@
+uid://a226b7bb41b142b5812d0d3dbd389a31
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc
new file mode 100644
index 0000000..2ae480f
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc
@@ -0,0 +1,7 @@
+:test
+ global result = "unset"
+ ?!
+ - "Run delayed"
+ test_delayed_set_global("result", "cmd", 0.01)
+ break
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc.uid
new file mode 100644
index 0000000..d89858e
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc.uid
@@ -0,0 +1 @@
+uid://e0743a93c87c4db4af251658723ed469
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc
new file mode 100644
index 0000000..e55b9a6
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc
@@ -0,0 +1,7 @@
+:test
+ global result = "start"
+ global count = 0
+ while count < 1:
+ test_delayed_set_global("result", "cmd", 0.01)
+ count = count + 1
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc.uid
new file mode 100644
index 0000000..e832298
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc.uid
@@ -0,0 +1 @@
+uid://c08224d490434f5dbe8529aa021c6041
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc
new file mode 100644
index 0000000..5aa9aed
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc
@@ -0,0 +1,5 @@
+:test
+ global result = "start"
+ global was_interrupted = false
+ test_interruptible_set_global("result", "cmd", "was_interrupted", 0.2)
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc.uid
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc.uid
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc
new file mode 100644
index 0000000..e9187b1
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc
@@ -0,0 +1,7 @@
+:test
+ global result = "start"
+ global was_interrupted = false
+ ?!
+ - "Wait"
+ test_interruptible_set_global("result", "cmd", "was_interrupted", 0.2)
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc.uid
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc.uid
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc
new file mode 100644
index 0000000..fc19824
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc
@@ -0,0 +1,8 @@
+:test
+ global result = "start"
+ global was_interrupted = false
+ global count = 0
+ while count < 3:
+ test_interruptible_set_global("result", "cmd", "was_interrupted", 0.2)
+ count = count + 1
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc.uid
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc.uid
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc
new file mode 100644
index 0000000..52ef2a4
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc
@@ -0,0 +1,4 @@
+:test
+ global result = "unset"
+ test_delayed_set_global("result", "cmd", 0.01)
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc.uid
new file mode 100644
index 0000000..a014454
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc.uid
@@ -0,0 +1 @@
+uid://bl7s16m8hlyj1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc
new file mode 100644
index 0000000..eaf762e
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc
@@ -0,0 +1,14 @@
+:talk
+ global result = "start"
+ global option1_is_valid = true
+ global option2_is_valid = true
+
+ ?!
+ - "option1" [option1_is_valid]
+ result = result + "-say1"
+ option1_is_valid = false
+ - "option2" [option2_is_valid]
+ result = result + "-say2"
+ option2_is_valid = false
+
+ result = result + "-finished"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc.uid
new file mode 100644
index 0000000..fac2cb3
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc.uid
@@ -0,0 +1 @@
+uid://cn1f4l17yg4x8
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc
new file mode 100644
index 0000000..69f88db
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc
@@ -0,0 +1,6 @@
+:talk
+ ?!
+ - UNCLE_SVEN:"Uncle Sven sends regards."
+ break
+ - "Fallback"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc.uid
new file mode 100644
index 0000000..d371627
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc.uid
@@ -0,0 +1 @@
+uid://dcjcq0w2qj1h2
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc
new file mode 100644
index 0000000..1be9d33
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc
@@ -0,0 +1,4 @@
+:talk
+ ?!
+ - OUTER_KEY:"INNER_KEY:Fallback text"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc.uid
new file mode 100644
index 0000000..aff1543
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc.uid
@@ -0,0 +1 @@
+uid://cb71rbl5a7hlj
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc
new file mode 100644
index 0000000..718a5fc
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc
@@ -0,0 +1,7 @@
+:talk
+ global show_first = true
+ ?!
+ - CONDITIONAL_KEY:"Conditional option" [show_first]
+ break
+ - HIDDEN_KEY:"Hidden option" [false]
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc.uid
new file mode 100644
index 0000000..4f944b1
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc.uid
@@ -0,0 +1 @@
+uid://bnep8t7ivgu7f
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc
new file mode 100644
index 0000000..587bd39
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc
@@ -0,0 +1,5 @@
+:talk
+ var option_text = "Dynamic option"
+ ?!
+ - DYNAMIC_KEY:option_text
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc.uid
new file mode 100644
index 0000000..56eee44
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc.uid
@@ -0,0 +1 @@
+uid://bf67f4h73tba2
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc
new file mode 100644
index 0000000..924cf52
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc
@@ -0,0 +1,9 @@
+:test
+ global result = "start"
+ var x = "outer"
+ ?!
+ - "Use inner"
+ var x = "inner"
+ result = x
+ break
+ result = result + ":" + x
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc.uid
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc.uid
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc
new file mode 100644
index 0000000..aab29c6
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc
@@ -0,0 +1,6 @@
+:talk
+ ?! "res://game/dialog_avatars/player.tres" 5 2
+ - "First option"
+ pass
+ - "Second option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc.uid
new file mode 100644
index 0000000..8a474a9
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc.uid
@@ -0,0 +1 @@
+uid://b3a4m3l6nr6m7
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc
new file mode 100644
index 0000000..368ef0b
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc
@@ -0,0 +1,4 @@
+:talk
+ ?! 123 5 2
+ - "Only option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc.uid
new file mode 100644
index 0000000..5708f33
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc.uid
@@ -0,0 +1 @@
+uid://chev11x0d0lg0
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc
new file mode 100644
index 0000000..06cab55
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc
@@ -0,0 +1,4 @@
+:talk
+ ?! "res://game/dialog_avatars/player.tres" 5 0 - 1
+ - "Only option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc.uid
new file mode 100644
index 0000000..a7cf6b8
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc.uid
@@ -0,0 +1 @@
+uid://db2w5uh1qvqst
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc
new file mode 100644
index 0000000..bc71bc7
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc
@@ -0,0 +1,4 @@
+:talk
+ ?! "res://game/dialog_avatars/player.tres" 5 "two"
+ - "Only option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc.uid
new file mode 100644
index 0000000..deb663d
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc.uid
@@ -0,0 +1 @@
+uid://b5fstn4fo0igo
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc
new file mode 100644
index 0000000..351be56
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc
@@ -0,0 +1,4 @@
+:talk
+ ?! "res://game/dialog_avatars/player.tres" "five" 2
+ - "Only option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc.uid
new file mode 100644
index 0000000..db53e65
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc.uid
@@ -0,0 +1 @@
+uid://cy8r7l4o3dmn0
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_hidden_authored_default_after_filtering.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_hidden_authored_default_after_filtering.esc
new file mode 100644
index 0000000..80f8ef7
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_hidden_authored_default_after_filtering.esc
@@ -0,0 +1,10 @@
+:talk
+ global show_first = false
+
+ ?! "-" 5 1
+ - "First option" [show_first]
+ break
+ - "Second option"
+ break
+ - "Third option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_visible_authored_default_after_filtering.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_visible_authored_default_after_filtering.esc
new file mode 100644
index 0000000..0067327
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_visible_authored_default_after_filtering.esc
@@ -0,0 +1,10 @@
+:talk
+ global show_first = false
+
+ ?! "-" 5 3
+ - "First option" [show_first]
+ break
+ - "Second option"
+ break
+ - "Third option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc
new file mode 100644
index 0000000..770224a
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc
@@ -0,0 +1,11 @@
+:test
+ global result = "start"
+ ?!
+ - "Outer"
+ ?!
+ - "Done"
+ done
+ result = "after_nested"
+ - "Exit outer"
+ break
+ result = result + "-done"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc.uid
new file mode 100644
index 0000000..9b3eaf5
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc.uid
@@ -0,0 +1 @@
+uid://catfpqk5u8vj0
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc
new file mode 100644
index 0000000..a37b050
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc
@@ -0,0 +1,7 @@
+:test
+ global result = 0
+ ?!
+ - "Outer"
+ if true:
+ done
+ result = 1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc.uid
new file mode 100644
index 0000000..8e5c281
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc.uid
@@ -0,0 +1 @@
+uid://bgxmdpje356jd
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc
new file mode 100644
index 0000000..8461cff
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc
@@ -0,0 +1,5 @@
+:use "wrench"
+ test_set_global("result", "first")
+
+:use "wrench"
+ test_set_global("result", "second")
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc.uid
new file mode 100644
index 0000000..600a3bf
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc.uid
@@ -0,0 +1 @@
+uid://cfpnxq6v1bg1t
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc
new file mode 100644
index 0000000..e11c818
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc
@@ -0,0 +1,15 @@
+:front_running
+ global global_interrupt_front_result = "start"
+ test_interruptible_set_global("global_interrupt_front_result", "front-cmd", "global_interrupt_front_was_interrupted", 0.2)
+ global_interrupt_front_result = global_interrupt_front_result + "-after"
+
+:front_queued
+ global_interrupt_front_result = "front-queued"
+
+:background_running
+ global global_interrupt_background_result = "start"
+ test_interruptible_set_global("global_interrupt_background_result", "bg-cmd", "global_interrupt_background_was_interrupted", 0.2)
+ global_interrupt_background_result = global_interrupt_background_result + "-after"
+
+:background_queued
+ global_interrupt_background_result = "bg-queued"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc.uid
new file mode 100644
index 0000000..5547f25
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc.uid
@@ -0,0 +1 @@
+uid://cje3uy4r8lh1x
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc
new file mode 100644
index 0000000..a9ffa26
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc
@@ -0,0 +1,17 @@
+:front_keep
+ global interrupt_except_front_result = "start"
+ test_interruptible_set_global("interrupt_except_front_result", "front-cmd", "interrupt_except_front_interrupted", 0.2)
+ interrupt_except_front_result = interrupt_except_front_result + "-after"
+
+:front_drop
+ interrupt_except_front_result = "front-drop"
+
+:background_drop
+ global interrupt_except_background_result = "start"
+ test_interruptible_set_global("interrupt_except_background_result", "bg-drop", "interrupt_except_background_interrupted", 0.2)
+ interrupt_except_background_result = interrupt_except_background_result + "-after"
+
+:background_keep
+ global interrupt_except_background_keep_result = "start"
+ test_delayed_set_global("interrupt_except_background_keep_result", "bg-keep", 0.01)
+ interrupt_except_background_keep_result = interrupt_except_background_keep_result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc.uid
new file mode 100644
index 0000000..dc38433
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc.uid
@@ -0,0 +1 @@
+uid://ceu2x1lbwfxby
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc
new file mode 100644
index 0000000..7d39c5f
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc
@@ -0,0 +1,4 @@
+:test
+ global result = "unset"
+ test_set_global("result", "cmd")
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc.uid
new file mode 100644
index 0000000..16cf665
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc.uid
@@ -0,0 +1 @@
+uid://dmf5wqf50nfnr
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc
new file mode 100644
index 0000000..fd1ba03
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc
@@ -0,0 +1,11 @@
+:first
+ global interpreter_runtime_result = "first"
+
+:second
+ interpreter_runtime_result = interpreter_runtime_result + "-second"
+
+:background_seed
+ global interpreter_runtime_background_result = "first"
+
+:background_after
+ interpreter_runtime_background_result = interpreter_runtime_background_result + "-background"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc.uid
new file mode 100644
index 0000000..cafce06
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc.uid
@@ -0,0 +1 @@
+uid://clqs3e5r4yem1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc
new file mode 100644
index 0000000..4d92d26
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc
@@ -0,0 +1,4 @@
+:test
+ global result = "start"
+ missing_test_command()
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc.uid
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc.uid
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc
new file mode 100644
index 0000000..2d59fb8
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc
@@ -0,0 +1,6 @@
+:test
+ global result = "start"
+ ?!
+ - "Break it"
+ missing_command()
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc.uid
new file mode 100644
index 0000000..77a66d7
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc.uid
@@ -0,0 +1 @@
+uid://d4esrhlw7qhri
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc
new file mode 100644
index 0000000..4ecf824
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc
@@ -0,0 +1,7 @@
+:test
+ global result = "start"
+ global count = 0
+ while count < 1:
+ missing_command()
+ count = count + 1
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc.uid
new file mode 100644
index 0000000..ec48741
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc.uid
@@ -0,0 +1 @@
+uid://ci5h4hpy7mjav
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc
new file mode 100644
index 0000000..a81c4c0
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc
@@ -0,0 +1,3 @@
+:test
+ global result = "unset"
+ result = "a" > 1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc.uid
new file mode 100644
index 0000000..67b9e76
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc.uid
@@ -0,0 +1 @@
+uid://bo4clnuxdy778
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc
new file mode 100644
index 0000000..10b036b
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc
@@ -0,0 +1,8 @@
+:look
+ say("player", "It's a leaking pipe.")
+
+:use "r5_wrench"
+ say("player", "The leak's now fixed.")
+
+:use "r5_pen"
+ say("player", "I don't see how I can fix the pipe with a pen.")
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc.uid
new file mode 100644
index 0000000..f77b460
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc.uid
@@ -0,0 +1 @@
+uid://cpdc2ha8v6svh
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc
new file mode 100644
index 0000000..82d638d
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc
@@ -0,0 +1,11 @@
+:test
+ global result = "start"
+ ?!
+ - "Outer"
+ ?!
+ - "Break nested"
+ break 1
+ result = "after_nested"
+ - "Exit outer"
+ break
+ result = result + "-done"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc.uid
new file mode 100644
index 0000000..8d392b8
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc.uid
@@ -0,0 +1 @@
+uid://bfwg7dby8q4g3
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc
new file mode 100644
index 0000000..07b5ccd
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc
@@ -0,0 +1,2 @@
+:test
+ print("a", "b")
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc.uid
new file mode 100644
index 0000000..cd16f5f
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc.uid
@@ -0,0 +1 @@
+uid://d0cv0a5fo7hdh
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc
new file mode 100644
index 0000000..3ff88bf
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc
@@ -0,0 +1,2 @@
+:test
+ print()
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc.uid
new file mode 100644
index 0000000..8dd21c3
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc.uid
@@ -0,0 +1 @@
+uid://cc274jba2egui
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc
new file mode 100644
index 0000000..853a985
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc
@@ -0,0 +1,10 @@
+:background_first
+ global block_wait_result = "start"
+ test_delayed_set_global("block_wait_result", "first", 0.05)
+
+:background_target
+ test_delayed_set_global("block_wait_result", "first-target", 0.2)
+
+:front_event
+ global block_wait_front_result = "start"
+ test_delayed_set_global("block_wait_front_result", "front", 0.01)
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc.uid
new file mode 100644
index 0000000..928dcef
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc.uid
@@ -0,0 +1 @@
+uid://4g0g2m5j3ve7u
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc
new file mode 100644
index 0000000..9ea46b0
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc
@@ -0,0 +1,10 @@
+:front_first
+ global front_block_wait_result = "start"
+ test_delayed_set_global("front_block_wait_result", "first", 0.05)
+
+:front_target
+ test_delayed_set_global("front_block_wait_result", "first-target", 0.2)
+
+:background_event
+ global front_block_wait_background_result = "start"
+ test_delayed_set_global("front_block_wait_background_result", "background", 0.01)
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc.uid
new file mode 100644
index 0000000..b725b6c
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc.uid
@@ -0,0 +1 @@
+uid://c5q5hi1sp5u4h
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc
new file mode 100644
index 0000000..475c523
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc
@@ -0,0 +1,7 @@
+:front_running
+ global scheduled_interrupt_result = "start"
+ test_interruptible_set_global("scheduled_interrupt_result", "front-running", "scheduled_interrupt_was_interrupted", 0.2)
+ scheduled_interrupt_result = scheduled_interrupt_result + "-after"
+
+:scheduled_front
+ scheduled_interrupt_result = scheduled_interrupt_result + "-scheduled"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc.uid
new file mode 100644
index 0000000..1ca4eb5
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc.uid
@@ -0,0 +1 @@
+uid://dd1tktxwp4t0s
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc
new file mode 100644
index 0000000..1f88f71
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc
@@ -0,0 +1,12 @@
+:front_running
+ global scheduled_front_result = "start"
+ test_interruptible_set_global("scheduled_front_result", "front-running", "scheduled_front_interrupted", 0.2)
+ scheduled_front_result = scheduled_front_result + "-after"
+
+:scheduled_front
+ scheduled_front_result = scheduled_front_result + "-scheduled"
+
+:background_running
+ global scheduled_background_result = "start"
+ test_interruptible_set_global("scheduled_background_result", "background-running", "scheduled_background_interrupted", 0.2)
+ scheduled_background_result = scheduled_background_result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc.uid
new file mode 100644
index 0000000..235d8c4
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc.uid
@@ -0,0 +1 @@
+uid://cqt1mxg5k7q6m
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc
new file mode 100644
index 0000000..7be3f85
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc
@@ -0,0 +1,6 @@
+:test
+ global result = 0
+ ?!
+ - "Stop"
+ stop
+ result = 1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc.uid
new file mode 100644
index 0000000..d979742
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc.uid
@@ -0,0 +1 @@
+uid://148e3f0660144f8a9e00065bdde07cde
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc
new file mode 100644
index 0000000..52ea799
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc
@@ -0,0 +1,5 @@
+:test
+ global result = 0
+ if true:
+ stop
+ result = 1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc.uid
new file mode 100644
index 0000000..f005816
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc.uid
@@ -0,0 +1 @@
+uid://ccw3t8r0s6n6r
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc
new file mode 100644
index 0000000..728ca59
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc
@@ -0,0 +1,11 @@
+:trigger_in "player"
+ test_set_global("trigger_in_result", "player-in")
+
+:trigger_in
+ test_set_global("trigger_in_result", "fallback-in")
+
+:trigger_out "player"
+ test_set_global("trigger_out_result", "player-out")
+
+:trigger_out
+ test_set_global("trigger_out_result", "fallback-out")
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc.uid
new file mode 100644
index 0000000..46d6dcb
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc.uid
@@ -0,0 +1 @@
+uid://ciepdqqwhcgtf
diff --git a/addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd
new file mode 100644
index 0000000..c0463b2
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd
@@ -0,0 +1,16 @@
+class_name TestDelayedSetGlobalCommand
+extends ESCBaseCommand
+
+
+func configure() -> ESCCommandArgumentDescriptor:
+ return ESCCommandArgumentDescriptor.new(
+ 3,
+ [[TYPE_STRING], [TYPE_STRING], [TYPE_FLOAT, TYPE_INT]],
+ [null, null, null]
+ )
+
+
+func run(command_params: Array) -> int:
+ await escoria.get_tree().create_timer(float(command_params[2])).timeout
+ escoria.globals_manager.set_global(command_params[0], command_params[1])
+ return ESCExecution.RC_OK
diff --git a/addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd.uid b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd.uid
new file mode 100644
index 0000000..f844e2a
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd.uid
@@ -0,0 +1 @@
+uid://b21sl821n61w4
diff --git a/addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd
new file mode 100644
index 0000000..2ea34a9
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd
@@ -0,0 +1,32 @@
+class_name TestInterruptibleSetGlobalCommand
+extends ESCBaseCommand
+
+
+var _interrupted := false
+
+
+func configure() -> ESCCommandArgumentDescriptor:
+ return ESCCommandArgumentDescriptor.new(
+ 4,
+ [[TYPE_STRING], [TYPE_STRING], [TYPE_STRING], [TYPE_FLOAT, TYPE_INT]],
+ [null, null, null, null]
+ )
+
+
+func run(command_params: Array) -> int:
+ _interrupted = false
+
+ var deadline_usec := Time.get_ticks_usec() + int(float(command_params[3]) * 1000000.0)
+ while Time.get_ticks_usec() < deadline_usec and not _interrupted:
+ await escoria.get_tree().create_timer(0.01).timeout
+
+ if _interrupted:
+ escoria.globals_manager.set_global(command_params[2], true)
+ return ESCExecution.RC_OK
+
+ escoria.globals_manager.set_global(command_params[0], command_params[1])
+ return ESCExecution.RC_OK
+
+
+func interrupt():
+ _interrupted = true
diff --git a/addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd.uid b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd.uid
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd.uid
diff --git a/addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd
new file mode 100644
index 0000000..41bd624
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd
@@ -0,0 +1,15 @@
+class_name TestSetGlobalCommand
+extends ESCBaseCommand
+
+
+func configure() -> ESCCommandArgumentDescriptor:
+ return ESCCommandArgumentDescriptor.new(
+ 2,
+ [[TYPE_STRING], [TYPE_STRING]],
+ [null, null]
+ )
+
+
+func run(command_params: Array) -> int:
+ escoria.globals_manager.set_global(command_params[0], command_params[1])
+ return ESCExecution.RC_OK
diff --git a/addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd.uid b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd.uid
new file mode 100644
index 0000000..b273d48
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd.uid
@@ -0,0 +1 @@
+uid://chb08gv4i2nlw
diff --git a/addons/escoria-core/testing/ashes/parser/esc_parser_test.gd b/addons/escoria-core/testing/ashes/parser/esc_parser_test.gd
new file mode 100644
index 0000000..8a2539c
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/esc_parser_test.gd
@@ -0,0 +1,586 @@
+# GdUnit generated TestSuite
+class_name ESCParserTest
+extends GdUnitTestSuite
+
+@warning_ignore("unused_parameter")
+@warning_ignore("return_value_discarded")
+
+
+func test_done_statement_does_not_consume_tokens_on_partial_match() -> void:
+ # This fixture is intentionally valid up to the dialog option body:
+ # `done` is in a dialog context, but `done x` is invalid because the parser
+ # only accepts `done` followed immediately by a newline.
+ var source := _load_fixture("done_partial_match.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("done_partial_match.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var done_index := _find_token_index(tokens, ESCTokenType.TokenType.DONE)
+ var print_index := _find_token_index(tokens, ESCTokenType.TokenType.IDENTIFIER, "print")
+ assert_int(done_index).is_greater_equal(0)
+ assert_int(print_index).is_greater_equal(0)
+
+ # Jump directly to the `done` token so this test exercises the statement
+ # parser in isolation. We also force dialog context because `done` is only
+ # legal inside dialogs.
+ parser._current = done_index
+ parser._dialog_level = 1
+
+ var statement = parser._statement()
+
+ # We expect a parse error, but we specifically do not want the parser to
+ # consume the `done` token before it discovers that the following token is
+ # not a newline. If it advances here, later recovery code starts from the
+ # wrong token and can corrupt the remaining parse.
+ assert_object(statement).is_instanceof(ESCParseError)
+ assert_bool(compiler.had_error).is_true()
+ assert_int(parser._current).is_equal(done_index)
+ assert_int(parser._peek().get_type()).is_equal(ESCTokenType.TokenType.DONE)
+ assert_int(tokens[print_index].get_type()).is_equal(ESCTokenType.TokenType.IDENTIFIER)
+ assert_str(tokens[print_index].get_lexeme()).is_equal("print")
+
+ # Tightening the regression: the next statement must still be present in the
+ # token stream after the failed `done` parse attempt. This protects against
+ # accidental over-consumption beyond the failing statement.
+ assert_int(_find_token_index_after(tokens, parser._current, ESCTokenType.TokenType.IDENTIFIER, "print")).is_equal(print_index)
+
+
+func test_parser_synchronizes_after_invalid_done_and_keeps_following_event() -> void:
+ # This fixture places an invalid `done` at the start of an event body and
+ # then follows it with a valid statement and a second event. The purpose is
+ # to verify parser recovery at ASHES statement and event boundaries.
+ var source := _load_fixture("synchronize_after_invalid_done.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("synchronize_after_invalid_done.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ # The parser should report an error for `done x`, but it should still recover
+ # well enough to continue parsing later declarations. In particular, the
+ # following event `:second` must survive recovery.
+ assert_bool(compiler.had_error).is_true()
+ assert_int(statements.size()).is_greater_equal(2)
+ assert_object(statements[0]).is_not_null()
+ assert_object(statements[1]).is_not_null()
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[1].get_event_name()).is_equal("second")
+
+
+func test_parser_synchronizes_after_invalid_if_and_keeps_following_event() -> void:
+ # This fixture uses a malformed `if` statement inside an event body. The
+ # missing colon forces a real statement parse error after the parser has
+ # already committed to the `if` branch. Recovery must still preserve the
+ # following top-level event declaration.
+ var source := _load_fixture("synchronize_after_invalid_if.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("synchronize_after_invalid_if.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_true()
+ assert_int(statements.size()).is_greater_equal(2)
+ assert_object(statements[0]).is_not_null()
+ assert_object(statements[1]).is_not_null()
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[1].get_event_name()).is_equal("second")
+
+
+func test_dialog_option_missing_right_square_returns_parse_error() -> void:
+ # This fixture omits the closing `]` from a dialog option predicate. The
+ # parser should report a parse error here rather than silently building a
+ # malformed dialog option node and continuing as if the condition was valid.
+ # Recovery should still preserve the following top-level event declaration.
+ var source := _load_fixture("dialog_option_missing_right_square.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("dialog_option_missing_right_square.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_true()
+ assert_int(statements.size()).is_greater_equal(2)
+ assert_object(statements[0]).is_not_null()
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("test")
+
+ var found_second := false
+ for statement in statements:
+ if statement is ESCGrammarStmts.Event and statement.get_event_name() == "second":
+ found_second = true
+ break
+
+ assert_bool(found_second).is_true()
+
+
+func test_dialog_option_missing_indented_body_returns_parse_error() -> void:
+ # This fixture omits the required indented body after a dialog option. The
+ # parser should surface the missing block-start error instead of continuing
+ # into `_block()` and constructing a malformed option node.
+ var source := _load_fixture("dialog_option_missing_indented_body.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("dialog_option_missing_indented_body.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_true()
+ assert_int(statements.size()).is_greater_equal(2)
+ assert_object(statements[0]).is_not_null()
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("test")
+
+ var found_second := false
+ for statement in statements:
+ if statement is ESCGrammarStmts.Event and statement.get_event_name() == "second":
+ found_second = true
+ break
+
+ assert_bool(found_second).is_true()
+
+
+func test_dialog_start_arguments_without_parens_parse_and_are_preserved() -> void:
+ # The public docs show dialog-start arguments in space-separated form:
+ # `?! "res://avatar" 5 2`. This regression locks that syntax down so the
+ # parser preserves those arguments instead of requiring the older `?!(...)`
+ # form.
+ var source := _load_fixture("dialog_start_args_without_parens.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("dialog_start_args_without_parens.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(2)
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+
+ var talk_event := statements[0] as ESCGrammarStmts.Event
+ var talk_block := talk_event.get_body() as ESCGrammarStmts.Block
+ assert_int(talk_block.get_statements().size()).is_equal(1)
+ assert_object(talk_block.get_statements()[0]).is_instanceof(ESCGrammarStmts.Dialog)
+
+ var dialog := talk_block.get_statements()[0] as ESCGrammarStmts.Dialog
+ var args := dialog.get_args()
+ assert_int(args.size()).is_equal(3)
+ assert_object(args[0]).is_instanceof(ESCGrammarExprs.Literal)
+ assert_str(args[0].get_value()).is_equal("res://game/dialog_avatars/player.tres")
+ assert_object(args[1]).is_instanceof(ESCGrammarExprs.Literal)
+ assert_float(args[1].get_value()).is_equal(5.0)
+ assert_object(args[2]).is_instanceof(ESCGrammarExprs.Literal)
+ assert_float(args[2].get_value()).is_equal(2.0)
+
+
+func test_dialog_start_arguments_with_parens_parse_and_are_preserved() -> void:
+ # Preserve the older parenthesized positional form as well, so both
+ # positional dialog-start syntaxes remain supported.
+ var source := _load_fixture("dialog_start_args_with_parens.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("dialog_start_args_with_parens.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(2)
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+
+ var talk_event := statements[0] as ESCGrammarStmts.Event
+ var talk_block := talk_event.get_body() as ESCGrammarStmts.Block
+ assert_int(talk_block.get_statements().size()).is_equal(1)
+ assert_object(talk_block.get_statements()[0]).is_instanceof(ESCGrammarStmts.Dialog)
+
+ var dialog := talk_block.get_statements()[0] as ESCGrammarStmts.Dialog
+ var args := dialog.get_args()
+ assert_int(args.size()).is_equal(3)
+ assert_object(args[0]).is_instanceof(ESCGrammarExprs.Literal)
+ assert_str(args[0].get_value()).is_equal("res://game/dialog_avatars/player.tres")
+ assert_object(args[1]).is_instanceof(ESCGrammarExprs.Literal)
+ assert_float(args[1].get_value()).is_equal(5.0)
+ assert_object(args[2]).is_instanceof(ESCGrammarExprs.Literal)
+ assert_float(args[2].get_value()).is_equal(2.0)
+
+
+func test_dialog_option_translation_key_is_parsed_explicitly() -> void:
+ var source := _load_fixture("dialog_option_translation_key.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("dialog_option_translation_key.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+
+ var talk_event := statements[0] as ESCGrammarStmts.Event
+ var talk_block := talk_event.get_body() as ESCGrammarStmts.Block
+ var dialog := talk_block.get_statements()[0] as ESCGrammarStmts.Dialog
+ var first_option := dialog.get_options()[0] as ESCGrammarStmts.DialogOption
+ var second_option := dialog.get_options()[1] as ESCGrammarStmts.DialogOption
+
+ assert_str(first_option.get_translation_key()).is_equal("UNCLE_SVEN")
+ assert_str(second_option.get_translation_key()).is_equal("")
+
+
+func test_grouping_missing_right_paren_returns_parse_error() -> void:
+ # This fixture omits the closing `)` from a grouped expression inside a
+ # function call. The parser must not silently accept the grouping and build
+ # a malformed AST; it should report the error and still preserve the
+ # following event declaration after recovery.
+ var source := _load_fixture("grouping_missing_right_paren.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("grouping_missing_right_paren.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_true()
+ assert_int(statements.size()).is_greater_equal(2)
+ assert_object(statements[0]).is_not_null()
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("test")
+ assert_object(statements[1]).is_not_null()
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[1].get_event_name()).is_equal("second")
+
+
+func test_var_grouping_missing_right_paren_returns_parse_error() -> void:
+ # This fixture omits the closing `)` from a grouped expression in a variable
+ # initializer. Unlike the call-expression regression above, this path goes
+ # straight through `_primary()`, so the parser must reject the malformed
+ # grouping instead of silently constructing a `Grouping` node.
+ var source := _load_fixture("var_grouping_missing_right_paren.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("var_grouping_missing_right_paren.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_true()
+ assert_int(statements.size()).is_greater_equal(2)
+ assert_object(statements[0]).is_not_null()
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("test")
+ assert_object(statements[1]).is_not_null()
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[1].get_event_name()).is_equal("second")
+
+
+func test_var_declaration_missing_newline_returns_parse_error() -> void:
+ # This fixture continues a variable declaration onto the same line with a
+ # second statement. The parser should not silently accept the declaration
+ # when the required trailing newline is missing, and recovery should still
+ # preserve the following event.
+ var source := _load_fixture("var_missing_newline.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("var_missing_newline.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_true()
+ assert_int(statements.size()).is_greater_equal(2)
+ assert_object(statements[0]).is_not_null()
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("test")
+ assert_object(statements[1]).is_not_null()
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[1].get_event_name()).is_equal("second")
+
+
+func test_global_declaration_missing_newline_returns_parse_error() -> void:
+ # This fixture continues a global declaration onto the same line with a
+ # second statement. The parser should reject the malformed declaration
+ # instead of constructing a partial `Global` node, and recovery should still
+ # preserve the following event.
+ var source := _load_fixture("global_missing_newline.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("global_missing_newline.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_true()
+ assert_int(statements.size()).is_greater_equal(2)
+ assert_object(statements[0]).is_not_null()
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("test")
+ assert_object(statements[1]).is_not_null()
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[1].get_event_name()).is_equal("second")
+
+
+func test_while_error_does_not_leak_loop_level_into_later_event() -> void:
+ # This fixture forces `_while_statement()` to fail after incrementing
+ # `_loop_level`. The later `break` in `:second` is top-level within that
+ # event body and must still be rejected. If the loop counter leaks across the
+ # error path, the parser will incorrectly accept it as a valid `Break`.
+ var source := _load_fixture("while_error_then_top_level_break.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("while_error_then_top_level_break.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_true()
+ assert_int(statements.size()).is_greater_equal(3)
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("test")
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[1].get_event_name()).is_equal("second")
+ assert_object(statements[2]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[2].get_event_name()).is_equal("third")
+
+ var second_body: Array = statements[1].get_body().get_statements()
+ assert_int(second_body.size()).is_equal(0)
+
+
+func test_dialog_error_does_not_leak_dialog_level_into_later_event() -> void:
+ # This fixture forces `_dialog_statement()` to fail after incrementing
+ # `_dialog_level`. The later `done` in `:second` is top-level within that
+ # event body and must still be rejected. If the dialog counter leaks across
+ # the error path, the parser will incorrectly accept it as a valid `Done`.
+ var source := _load_fixture("dialog_error_then_top_level_done.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("dialog_error_then_top_level_done.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_true()
+ assert_int(statements.size()).is_greater_equal(3)
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("test")
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[1].get_event_name()).is_equal("second")
+ assert_object(statements[2]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[2].get_event_name()).is_equal("third")
+
+ var second_body: Array = statements[1].get_body().get_statements()
+ assert_int(second_body.size()).is_equal(0)
+
+
+func test_happy_path_full_language_fixture_parses_without_errors() -> void:
+ # This smoke fixture exercises a broad cross-section of ASHES syntax on the
+ # parser happy path: event flags, targets, globals, locals, branching,
+ # looping, built-ins, dialog, nested dialog, conditional dialog options,
+ # `break`, `done`, `in inventory`, `is active`, and `is "state"`.
+ var source := _load_fixture("happy_path_full_language.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("happy_path_full_language.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(2)
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("setup")
+ assert_int(statements[0].get_num_statements_in_block()).is_equal(6)
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[1].get_event_name()).is_equal("look")
+ assert_str(statements[1].get_target_name()).is_equal("worker")
+ assert_int(statements[1].get_num_statements_in_block()).is_equal(3)
+
+
+func test_keyword_prefixed_names_scan_as_identifiers() -> void:
+ # ASHES keywords should only match on exact identifier text. Names that
+ # merely start with keyword text must still scan as ordinary identifiers.
+ var source := _load_fixture("identifier_keyword_prefixes.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("identifier_keyword_prefixes.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_false()
+ assert_int(_find_token_index(tokens, ESCTokenType.TokenType.IDENTIFIER, "breakfast")).is_greater_equal(0)
+ assert_int(_find_token_index(tokens, ESCTokenType.TokenType.IDENTIFIER, "donee")).is_greater_equal(0)
+ assert_int(_find_token_index(tokens, ESCTokenType.TokenType.IDENTIFIER, "elseify")).is_greater_equal(0)
+ assert_int(_find_token_index(tokens, ESCTokenType.TokenType.IDENTIFIER, "notable")).is_greater_equal(0)
+ assert_int(_find_token_index(tokens, ESCTokenType.TokenType.BREAK, "break")).is_equal(-1)
+ assert_int(_find_token_index(tokens, ESCTokenType.TokenType.DONE, "done")).is_equal(-1)
+ assert_int(_find_token_index(tokens, ESCTokenType.TokenType.ELSE, "else")).is_equal(-1)
+ assert_int(_find_token_index(tokens, ESCTokenType.TokenType.NOT, "not")).is_equal(-1)
+ assert_int(statements.size()).is_equal(1)
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("test")
+
+
+func test_hyphenated_global_identifier_scans_as_single_identifier() -> void:
+ # `$`-prefixed global-ID shorthand may contain hyphens, but ordinary `-`
+ # characters must still scan as subtraction operators outside that prefix.
+ var source := _load_fixture("hyphenated_global_identifier.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("hyphenated_global_identifier.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_false()
+ assert_int(_find_token_index(tokens, ESCTokenType.TokenType.IDENTIFIER, "$closet-door")).is_greater_equal(0)
+ assert_int(_find_token_index(tokens, ESCTokenType.TokenType.MINUS, "-")).is_greater_equal(0)
+ assert_int(_find_token_index(tokens, ESCTokenType.TokenType.NUMBER, "1")).is_greater_equal(0)
+ assert_int(statements.size()).is_equal(1)
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("test")
+
+
+func test_blank_lines_in_nested_blocks_do_not_break_later_events() -> void:
+ # Blank lines inside nested blocks should not corrupt INDENT/DEDENT handling.
+ # The parser must still recover the later top-level event after the nested
+ # block ends.
+ var source := _load_fixture("blank_lines_in_nested_blocks.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("blank_lines_in_nested_blocks.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(2)
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("test")
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[1].get_event_name()).is_equal("second")
+
+
+func test_blank_lines_before_dedent_do_not_hide_block_termination() -> void:
+ # Blank lines immediately before a block-closing dedent should not be eaten
+ # in a way that hides the dedent from `_block()`. The outer statement after
+ # the nested block, plus the later top-level event, must still parse.
+ var source := _load_fixture("blank_lines_before_dedent.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("blank_lines_before_dedent.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+ var statements := parser.parse()
+
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(2)
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[0].get_event_name()).is_equal("test")
+ assert_object(statements[1]).is_instanceof(ESCGrammarStmts.Event)
+ assert_str(statements[1].get_event_name()).is_equal("second")
+ assert_int(statements[0].get_num_statements_in_block()).is_equal(2)
+
+
+func _load_fixture(name: String) -> String:
+ return FileAccess.get_file_as_string(_fixture_path(name))
+
+
+func _fixture_path(name: String) -> String:
+ return "res://addons/escoria-core/testing/ashes/parser/fixtures/%s" % name
+
+
+func _find_token_index(tokens: Array, token_type: int, lexeme: String = "") -> int:
+ for index in range(tokens.size()):
+ var token = tokens[index]
+ if token.get_type() == token_type and (lexeme.is_empty() or token.get_lexeme() == lexeme):
+ return index
+
+ return -1
+
+
+func _find_token_index_after(tokens: Array, current_index: int, token_type: int, lexeme: String = "") -> int:
+ for index in range(current_index + 1, tokens.size()):
+ var token = tokens[index]
+ if token.get_type() == token_type and (lexeme.is_empty() or token.get_lexeme() == lexeme):
+ return index
+
+ return -1
diff --git a/addons/escoria-core/testing/ashes/parser/esc_parser_test.gd.uid b/addons/escoria-core/testing/ashes/parser/esc_parser_test.gd.uid
new file mode 100644
index 0000000..e97a2bb
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/esc_parser_test.gd.uid
@@ -0,0 +1 @@
+uid://di2o7dahssqsc
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc
new file mode 100644
index 0000000..0ba7826
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc
@@ -0,0 +1,9 @@
+:test
+ if true:
+ print("inside")
+
+
+ print("after")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc.uid
new file mode 100644
index 0000000..4287f2f
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_before_dedent.esc.uid
@@ -0,0 +1 @@
+uid://dc3kyx3rnh13d
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc
new file mode 100644
index 0000000..f1bf7dc
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc
@@ -0,0 +1,11 @@
+:test
+ if true:
+ var value = 1
+
+ if true:
+ print(value)
+
+ print("done")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc.uid
new file mode 100644
index 0000000..5d9dc6a
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/blank_lines_in_nested_blocks.esc.uid
@@ -0,0 +1 @@
+uid://c6pkm7n2t4wyf
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc
new file mode 100644
index 0000000..a6aa528
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc
@@ -0,0 +1,10 @@
+:test
+ ?!(1,
+ - "Option"
+ print("broken")
+
+:second
+ done
+
+:third
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc.uid
new file mode 100644
index 0000000..13ee072
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_error_then_top_level_done.esc.uid
@@ -0,0 +1 @@
+uid://f4q8q8et7mbo2
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc
new file mode 100644
index 0000000..51c525c
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc
@@ -0,0 +1,7 @@
+:test
+ ?!
+ - "Option"
+ print("after")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc.uid
new file mode 100644
index 0000000..7bd0fe8
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_indented_body.esc.uid
@@ -0,0 +1 @@
+uid://blnqmv4wjsoi8
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_right_square.esc b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_right_square.esc
new file mode 100644
index 0000000..32f407a
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_missing_right_square.esc
@@ -0,0 +1,9 @@
+:test
+ ?!
+ - "Option" [some_flag < 3
+ print("after")
+ - "Second option"
+ print("ok")
+
+:second
+ print("after_dialog")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc
new file mode 100644
index 0000000..69f88db
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc
@@ -0,0 +1,6 @@
+:talk
+ ?!
+ - UNCLE_SVEN:"Uncle Sven sends regards."
+ break
+ - "Fallback"
+ break
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc.uid
new file mode 100644
index 0000000..7ac79a6
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_option_translation_key.esc.uid
@@ -0,0 +1 @@
+uid://doadggrcx5l6l
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc
new file mode 100644
index 0000000..0b3c59e
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc
@@ -0,0 +1,9 @@
+:talk
+ ?!("res://game/dialog_avatars/player.tres", 5, 2)
+ - "First option"
+ say($player, "First option")
+ - "Second option"
+ say($player, "Second option")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc.uid
new file mode 100644
index 0000000..076398a
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_with_parens.esc.uid
@@ -0,0 +1 @@
+uid://d0xvqlf18ax11
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc
new file mode 100644
index 0000000..cdb2090
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc
@@ -0,0 +1,9 @@
+:talk
+ ?! "res://game/dialog_avatars/player.tres" 5 2
+ - "First option"
+ say($player, "First option")
+ - "Second option"
+ say($player, "Second option")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc.uid
new file mode 100644
index 0000000..d0dd054
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/dialog_start_args_without_parens.esc.uid
@@ -0,0 +1 @@
+uid://cb2w2jdpxy8sr
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/done_partial_match.esc b/addons/escoria-core/testing/ashes/parser/fixtures/done_partial_match.esc
new file mode 100644
index 0000000..a36a85b
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/done_partial_match.esc
@@ -0,0 +1,5 @@
+:test
+ ?!
+ - "Option"
+ done x
+ print("after")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc b/addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc
new file mode 100644
index 0000000..f51f093
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc
@@ -0,0 +1,5 @@
+:test
+ global value = 1 print("oops")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc.uid
new file mode 100644
index 0000000..bcb4fd1
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/global_missing_newline.esc.uid
@@ -0,0 +1 @@
+uid://di8w10jb2c7u4
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc b/addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc
new file mode 100644
index 0000000..97e2e1e
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc
@@ -0,0 +1,5 @@
+:test
+ print((1 + 2)
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc.uid
new file mode 100644
index 0000000..a872107
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/grouping_missing_right_paren.esc.uid
@@ -0,0 +1 @@
+uid://dnj6r3c3d8m5x
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc b/addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc
new file mode 100644
index 0000000..0a82acd
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc
@@ -0,0 +1,41 @@
+:setup | NO_UI | NO_SAVE
+ global has_met_worker = false
+ global ask_count = 0
+ var local_count = 0
+ var target_name = $worker
+
+ if !has_met_worker and ESC_LAST_SCENE == "room1":
+ print("first visit")
+ elif $worker is active:
+ print("worker active")
+ else:
+ print("fallback")
+
+ while local_count < 3:
+ local_count = local_count + 1
+ if local_count == 2:
+ break
+
+:look "worker"
+ if $badge in inventory:
+ print("has badge")
+ elif $worker is "busy":
+ print("busy")
+ else:
+ print("idle")
+
+ ?!
+ - "Who are you?" [!has_met_worker]
+ print("ask name")
+ has_met_worker = true
+ - "Tell me more." [ask_count < 2]
+ ask_count = ask_count + 1
+ ?!
+ - "Enough."
+ break 1
+ - "I am done."
+ done
+ - "Bye."
+ break
+
+ print("after dialog")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc.uid
new file mode 100644
index 0000000..d6713b5
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/happy_path_full_language.esc.uid
@@ -0,0 +1 @@
+uid://cbv6x0w8g17ap
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc b/addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc
new file mode 100644
index 0000000..0c86561
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc
@@ -0,0 +1,2 @@
+:test
+ var total = $closet-door - 1
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc.uid
new file mode 100644
index 0000000..f2e55d8
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/hyphenated_global_identifier.esc.uid
@@ -0,0 +1 @@
+uid://b2mxj0r3fk4yy
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc b/addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc
new file mode 100644
index 0000000..dd86b8f
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc
@@ -0,0 +1,6 @@
+:test
+ var breakfast = 1
+ var donee = 2
+ var elseify = 3
+ var notable = 4
+ print(breakfast + donee + elseify + notable)
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc.uid
new file mode 100644
index 0000000..3bdfa1b
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/identifier_keyword_prefixes.esc.uid
@@ -0,0 +1 @@
+uid://d1s8r2n3kq1vh
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_done.esc b/addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_done.esc
new file mode 100644
index 0000000..0203977
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_done.esc
@@ -0,0 +1,6 @@
+:test
+ done x
+ print("after")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_if.esc b/addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_if.esc
new file mode 100644
index 0000000..d871c98
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/synchronize_after_invalid_if.esc
@@ -0,0 +1,7 @@
+:test
+ if some_flag
+ print("broken")
+ print("after")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc b/addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc
new file mode 100644
index 0000000..05fd749
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc
@@ -0,0 +1,5 @@
+:test
+ var x = (1 + 2
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc.uid
new file mode 100644
index 0000000..567b162
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/var_grouping_missing_right_paren.esc.uid
@@ -0,0 +1 @@
+uid://b2d1d5qj4o8t7
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc b/addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc
new file mode 100644
index 0000000..c62d37d
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc
@@ -0,0 +1,5 @@
+:test
+ var x = 1 print("bad")
+
+:second
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc.uid
new file mode 100644
index 0000000..f65cfb9
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/var_missing_newline.esc.uid
@@ -0,0 +1 @@
+uid://dssmdd6b0cwut
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc b/addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc
new file mode 100644
index 0000000..dd6ef8b
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc
@@ -0,0 +1,9 @@
+:test
+ while true
+ print("broken")
+
+:second
+ break
+
+:third
+ print("ok")
diff --git a/addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc.uid b/addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc.uid
new file mode 100644
index 0000000..1a1f894
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/parser/fixtures/while_error_then_top_level_break.esc.uid
@@ -0,0 +1 @@
+uid://dgo0jap62bmth
diff --git a/addons/escoria-core/testing/esc_new_game_state_test.gd b/addons/escoria-core/testing/esc_new_game_state_test.gd
new file mode 100644
index 0000000..7304d6a
--- /dev/null
+++ b/addons/escoria-core/testing/esc_new_game_state_test.gd
@@ -0,0 +1,38 @@
+# GdUnit generated TestSuite
+class_name ESCNewGameStateTest
+extends GdUnitTestSuite
+
+
+func test_new_game_runtime_reset_clears_transient_input_state() -> void:
+ var escoria_node = escoria.get_escoria()
+ var tool_node := ESCItem.new()
+ tool_node.global_id = "test_tool"
+ var tool_object := ESCObject.new(tool_node.global_id, tool_node)
+ var hovered_item := ESCItem.new()
+ hovered_item.global_id = "test_hovered_item"
+ hovered_item.z_index = 1
+
+ escoria.action_manager.set_current_action("use")
+ escoria.action_manager.current_tool = tool_object
+ escoria.action_manager.current_target = tool_object
+ escoria.action_manager.set_action_input_state(
+ ESCActionManager.ActionInputState.AWAITING_TARGET_ITEM
+ )
+ escoria.inputs_manager.hover_stack.hover_stack.append(hovered_item)
+ escoria.inputs_manager.hotspot_focused = "test_hotspot"
+ escoria.inputs_manager.input_mode = escoria.inputs_manager.INPUT_NONE
+
+ escoria_node._reset_new_game_runtime_state()
+
+ assert_str(escoria.action_manager.current_action).is_equal("")
+ assert_object(escoria.action_manager.current_tool).is_null()
+ assert_object(escoria.action_manager.current_target).is_null()
+ assert_int(escoria.action_manager.action_state).is_equal(
+ ESCActionManager.ActionInputState.AWAITING_VERB_OR_ITEM
+ )
+ assert_bool(escoria.inputs_manager.hover_stack.is_empty()).is_true()
+ assert_str(escoria.inputs_manager.hotspot_focused).is_equal("")
+ assert_int(escoria.inputs_manager.input_mode).is_equal(escoria.inputs_manager.INPUT_ALL)
+
+ tool_node.free()
+ hovered_item.free()
diff --git a/addons/escoria-core/testing/esc_new_game_state_test.gd.uid b/addons/escoria-core/testing/esc_new_game_state_test.gd.uid
new file mode 100644
index 0000000..a25bd5d
--- /dev/null
+++ b/addons/escoria-core/testing/esc_new_game_state_test.gd.uid
@@ -0,0 +1 @@
+uid://dce1ff86rtpla
diff --git a/addons/escoria-core/testing/esc_object_manager_test.gd b/addons/escoria-core/testing/esc_object_manager_test.gd
new file mode 100644
index 0000000..52f79b3
--- /dev/null
+++ b/addons/escoria-core/testing/esc_object_manager_test.gd
@@ -0,0 +1,79 @@
+# GdUnit generated TestSuite
+class_name ESCObjectManagerTest
+extends GdUnitTestSuite
+
+
+func test_reset_game_state_clears_persisted_room_object_state() -> void:
+ var object_manager := ESCObjectManager.new()
+ var room := ESCRoom.new()
+ room.global_id = "test_room"
+ object_manager.set_current_room(room)
+
+ var first_item := ESCItem.new()
+ first_item.global_id = "test_item"
+ var first_object := ESCObject.new(first_item.global_id, first_item)
+ object_manager.register_object(first_object, room, false, false)
+ first_object.set_state("opened")
+
+ var room_key := ESCRoomObjectsKey.new()
+ room_key.room_global_id = room.global_id
+ room_key.room_instance_id = room.get_instance_id()
+ object_manager.unregister_object(first_object, room_key)
+ assert_bool(object_manager.has(first_item.global_id, room)).is_true()
+
+ object_manager.reset_game_state()
+ assert_bool(object_manager.has(first_item.global_id, room)).is_false()
+
+ var second_item := ESCItem.new()
+ second_item.global_id = "test_item"
+ var second_object := ESCObject.new(second_item.global_id, second_item)
+ object_manager.set_current_room(room)
+ object_manager.register_object(second_object, room, false, false)
+
+ assert_str(object_manager.get_object(second_item.global_id, room).state).is_equal(
+ ESCObject.STATE_DEFAULT
+ )
+
+ first_item.free()
+ second_item.free()
+ room.free()
+
+
+func test_reset_game_state_preserves_reserved_objects() -> void:
+ var object_manager := ESCObjectManager.new()
+ var music_node := Node.new()
+
+ object_manager.register_object(
+ ESCObject.new(ESCObjectManager.MUSIC, music_node),
+ null,
+ true,
+ false
+ )
+
+ object_manager.reset_game_state()
+
+ assert_bool(object_manager.has(ESCObjectManager.MUSIC)).is_true()
+ assert_object(object_manager.get_object(ESCObjectManager.MUSIC).node).is_same(music_node)
+
+ music_node.free()
+
+
+func test_reset_game_state_clears_saved_terrain_state() -> void:
+ var object_manager := ESCObjectManager.new()
+ var room := ESCRoom.new()
+ room.global_id = "test_room"
+ var navigation_region := NavigationRegion2D.new()
+
+ object_manager.set_current_room(room)
+ object_manager.register_terrain(
+ ESCObject.new("test_terrain", navigation_region),
+ room
+ )
+ assert_int(object_manager.room_terrains.size()).is_equal(1)
+
+ object_manager.reset_game_state()
+
+ assert_int(object_manager.room_terrains.size()).is_equal(0)
+
+ navigation_region.free()
+ room.free()
diff --git a/addons/escoria-core/testing/esc_object_manager_test.gd.uid b/addons/escoria-core/testing/esc_object_manager_test.gd.uid
new file mode 100644
index 0000000..55ec250
--- /dev/null
+++ b/addons/escoria-core/testing/esc_object_manager_test.gd.uid
@@ -0,0 +1 @@
+uid://b5f3fwnatn16n
diff --git a/addons/escoria-core/testing/issue_946_room_transition_test.gd b/addons/escoria-core/testing/issue_946_room_transition_test.gd
new file mode 100644
index 0000000..937f441
--- /dev/null
+++ b/addons/escoria-core/testing/issue_946_room_transition_test.gd
@@ -0,0 +1,104 @@
+class_name Issue946RoomTransitionTest
+extends GdUnitTestSuite
+
+
+const MARK_SCRIPT := "res://game/characters/mark/mark.esc"
+const ROOM_05 := "res://game/rooms/room05/room05.tscn"
+const ROOM_06 := "res://game/rooms/room06/room06.tscn"
+const EVENT_FINISHED_TIMEOUT_SECONDS := 5.0
+
+
+var _compiler_before: ESCCompiler
+var _event_finished_rc: Variant = []
+var _event_finished_timed_out := false
+
+
+class CountingCompiler extends ESCCompiler:
+ var load_counts: Dictionary = {}
+
+
+ func load_esc_file(path: String, associated_global_id: String = "") -> ESCScript:
+ load_counts[path] = load_counts.get(path, 0) + 1
+ return super.load_esc_file(path, associated_global_id)
+
+
+ func get_load_count(path: String) -> int:
+ return load_counts.get(path, 0)
+
+
+ func reset_counts() -> void:
+ load_counts.clear()
+
+
+func before() -> void:
+ _compiler_before = escoria.esc_compiler
+ escoria.esc_compiler = CountingCompiler.new()
+ escoria.event_manager.interrupt([], false)
+ escoria.event_manager.clear_event_queue()
+
+
+func after() -> void:
+ escoria.event_manager.interrupt([], false)
+ escoria.event_manager.clear_event_queue()
+ escoria.esc_compiler = _compiler_before
+
+
+func test_room_6_to_room_5_loads_player_script_once() -> void:
+ var compiler := escoria.esc_compiler as CountingCompiler
+
+ await _change_scene_and_wait(ROOM_06, false)
+
+ compiler.reset_counts()
+
+ await _change_scene_and_wait(ROOM_05, true)
+ await _wait_for_frames(3)
+
+ assert_object(escoria.object_manager.get_object("player")).is_not_null()
+ assert_int(compiler.get_load_count(MARK_SCRIPT)).is_equal(1)
+
+
+func _change_scene_and_wait(room_path: String, enable_automatic_transition: bool) -> void:
+ var room_ready: Signal = escoria.main.room_ready
+ ChangeSceneCommand.new().run([room_path, enable_automatic_transition])
+ await room_ready
+ if enable_automatic_transition:
+ await _wait_for_event_finished(escoria.event_manager.EVENT_TRANSITION_IN)
+
+
+func _wait_for_event_finished(event_name: String) -> void:
+ _event_finished_rc = []
+ _event_finished_timed_out = false
+
+ escoria.event_manager.event_finished.connect(_on_wait_event_finished)
+ var timeout_timer := get_tree().create_timer(EVENT_FINISHED_TIMEOUT_SECONDS)
+ timeout_timer.timeout.connect(_on_wait_event_finished_timeout)
+
+ while not _event_finished_timed_out:
+ await get_tree().process_frame
+ var rc := _event_finished_rc
+ if rc is Array and rc.size() > 1 and rc[1] == event_name:
+ escoria.event_manager.event_finished.disconnect(_on_wait_event_finished)
+ if timeout_timer.timeout.is_connected(_on_wait_event_finished_timeout):
+ timeout_timer.timeout.disconnect(_on_wait_event_finished_timeout)
+ return
+
+ if escoria.event_manager.event_finished.is_connected(_on_wait_event_finished):
+ escoria.event_manager.event_finished.disconnect(_on_wait_event_finished)
+
+ fail(
+ "Timed out waiting for escoria.event_manager.event_finished for event '%s'."
+ % event_name
+ )
+
+
+func _on_wait_event_finished(return_code, finished_event_name) -> void:
+ _event_finished_rc = [return_code, finished_event_name]
+
+
+func _on_wait_event_finished_timeout() -> void:
+ _event_finished_timed_out = true
+
+
+func _wait_for_frames(count: int) -> void:
+ for frame in count:
+ await get_tree().process_frame
diff --git a/addons/escoria-core/testing/issue_946_room_transition_test.gd.uid b/addons/escoria-core/testing/issue_946_room_transition_test.gd.uid
new file mode 100644
index 0000000..5af4b20
--- /dev/null
+++ b/addons/escoria-core/testing/issue_946_room_transition_test.gd.uid
@@ -0,0 +1 @@
+uid://dd4msfbyuho46
diff --git a/addons/escoria-core/testing/player_angles_finder.gd b/addons/escoria-core/testing/player_angles_finder.gd
index e47b83c..3c2c48a 100644
--- a/addons/escoria-core/testing/player_angles_finder.gd
+++ b/addons/escoria-core/testing/player_angles_finder.gd
@@ -8,11 +8,7 @@ extends Node2D
# Clicking under his feet is angle 180.
# etc.
-var number_of_directions: int
-var angle_horizontal_axes: float
-var angle_vertical_axes: float
-var angle_diagonal_axes: float
-const POLYGON_DISTANCE = 400
+
enum Directions {
NORTH, # 0
@@ -25,7 +21,9 @@ enum Directions {
NORTHWEST # 7
}
-const starting_angles = [
+const POLYGON_DISTANCE = 400
+
+const STARTING_ANGLES = [
0, # 0 NORTH
PI/4, # 1 NORTHEAST
PI/2, # 2 EAST
@@ -36,6 +34,12 @@ const starting_angles = [
7*PI/4, # 7 NORTHWEST
]
+var number_of_directions: int
+var angle_horizontal_axes: float
+var angle_vertical_axes: float
+var angle_diagonal_axes: float
+
+
var colors = [
Color("red"), # 0 NORTH
Color("green"), # 1 NORTHEAST
@@ -153,7 +157,7 @@ func calculate_areas(nb_directions: int = 8):
# Then minus angle_area/2 to align on direction
start_angle = PI/2 + angle_area / 2
- var angle_start = starting_angles[i] - start_angle
+ var angle_start = STARTING_ANGLES[i] - start_angle
var angle_end = angle_start + angle_area
angles.push_back([angle_start, angle_end])
@@ -217,7 +221,7 @@ func _on_angle_diag_text_changed(new_text: String):
calculate_areas()
-func _on_area_click(viewport: Object, event: InputEvent, shape_idx: int, area_name: String):
+func _on_area_click(_viewport: Object, event: InputEvent, _shape_idx: int, _area_name: String):
if event is InputEventMouseButton and event.is_pressed():
pass
diff --git a/addons/escoria-core/testing/rtl_screen_offset_testing.gd b/addons/escoria-core/testing/rtl_screen_offset_testing.gd
index 4083bbe..87de0bc 100644
--- a/addons/escoria-core/testing/rtl_screen_offset_testing.gd
+++ b/addons/escoria-core/testing/rtl_screen_offset_testing.gd
@@ -1,17 +1,20 @@
extends Control
-@onready var screen_width = get_tree().get_root().get_viewport().size.x
-@onready var screen_height = get_tree().get_root().get_viewport().size.y
-var global_distance_to_clamp = 40: get = get_global_dist_clamp, set = set_global_dist_clamp
-
signal mouse_moved(position)
signal text_selected(text)
-@export var path_to_richtextlabel: NodePath
-const ONE_LINE_HEIGHT = 16
-@export var max_width: int = 200
const MIN_HEIGHT = 30
const MAX_HEIGHT = 500
+const ONE_LINE_HEIGHT = 16
+
+@export var path_to_richtextlabel: NodePath
+@export var max_width: int = 200
+
+var global_distance_to_clamp = 40: get = get_global_dist_clamp, set = set_global_dist_clamp
+
+@onready var screen_width = get_tree().get_root().get_viewport().size.x
+@onready var screen_height = get_tree().get_root().get_viewport().size.y
+
func _ready():
assert(!path_to_richtextlabel.is_empty())
diff --git a/addons/escoria-core/tools/logging/esc_logger.gd b/addons/escoria-core/tools/logging/esc_logger.gd
index 26d8d86..52bfcde 100644
--- a/addons/escoria-core/tools/logging/esc_logger.gd
+++ b/addons/escoria-core/tools/logging/esc_logger.gd
@@ -21,14 +21,13 @@ class ESCLoggerBase:
## Log filename format
const LOG_FILE_FORMAT: String = "log_%s_%s.log"
- # Configured log level
- var _log_level: int
-
## If true, assert() functions will not be called, thus the program won't
## exit or error. Resets to false after an assert() call was ignored once.
## Useful for console calls.
var dont_assert: bool = false
+ # Configured log level
+ var _log_level: int
# Constructor
func _init():
diff --git a/addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd b/addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd
index 3ed0025..038d3f2 100644
--- a/addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd
+++ b/addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd
@@ -1,5 +1,5 @@
-extends ESCScriptAnalyzer
class_name ESCExitSceneScriptAnalyzer
+extends ESCScriptAnalyzer
const EXIT_SCENE_EVENT_NAME = "exit_scene"
@@ -18,9 +18,9 @@ const BULLET_CHARACTER = "- "
# These are vars because they have to be, but should be treated as consts
-var TRANSITION_COMMAND_NAME = TransitionCommand.new().get_command_name()
-var CHANGE_SCENE_COMMAND_NAME = ChangeSceneCommand.new().get_command_name()
-var ACCEPT_INPUT_COMMAND_NAME = AcceptInputCommand.new().get_command_name()
+var transition_command_name = TransitionCommand.new().get_command_name()
+var change_scene_command_name = ChangeSceneCommand.new().get_command_name()
+var accept_input_command_name = AcceptInputCommand.new().get_command_name()
var _has_change_scene_command: bool = false
@@ -96,7 +96,7 @@ func visit_call_expr(expr: ESCGrammarExprs.Call):
_has_commands_after_change_scene_command = _has_change_scene_command_in_scope()
- if command_name == CHANGE_SCENE_COMMAND_NAME:
+ if command_name == change_scene_command_name:
_has_change_scene_command = true
_change_scene_token = expr.get_paren_token()
@@ -109,9 +109,9 @@ func visit_call_expr(expr: ESCGrammarExprs.Call):
if is_auto_transition != null and not is_auto_transition:
_check_for_missing_accept_input_disable()
- elif command_name == TRANSITION_COMMAND_NAME:
+ elif command_name == transition_command_name:
_check_for_missing_accept_input_disable()
- elif command_name == ACCEPT_INPUT_COMMAND_NAME:
+ elif command_name == accept_input_command_name:
var first_arg = args[0] as String
if first_arg != null and first_arg.to_lower() in ACCEPT_INPUT_DISABLE_ARGS:
diff --git a/addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd b/addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd
index 874ffa1..2771e43 100644
--- a/addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd
+++ b/addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd
@@ -1,5 +1,5 @@
-extends RefCounted
class_name ESCScriptAnalyzer
+extends RefCounted
const CURRENT_PLAYER_KEYWORD = "CURRENT_PLAYER"
@@ -18,7 +18,7 @@ var _builtin_functions: Array = [
# This must be implemented in child class.
-func analyze(statements: Array) -> void:
+func analyze(_statements: Array) -> void:
pass
@@ -75,8 +75,7 @@ func visit_call_expr(expr: ESCGrammarExprs.Call):
if not callee is ESCBaseCommand:
if not callee in _builtin_functions:
return 0
- else:
- return _handle_builtin_function(callee, args)
+ return _handle_builtin_function(callee, args)
return ESCExecution.RC_OK
@@ -115,7 +114,7 @@ func visit_while_stmt(stmt: ESCGrammarStmts.While):
return null
-func visit_pass_stmt(stmt: ESCGrammarStmts.Pass):
+func visit_pass_stmt(_stmt: ESCGrammarStmts.Pass):
pass
@@ -147,11 +146,11 @@ func visit_global_stmt(stmt: ESCGrammarStmts.Global):
return null
-func visit_dialog_stmt(stmt: ESCGrammarStmts.Dialog):
+func visit_dialog_stmt(_stmt: ESCGrammarStmts.Dialog):
return null
-func visit_dialog_option_stmt(stmt: ESCGrammarStmts.DialogOption):
+func visit_dialog_option_stmt(_stmt: ESCGrammarStmts.DialogOption):
pass
@@ -295,7 +294,7 @@ func _look_up_object(name: ESCToken):
# We have no way of knowing what the value will actually be since this is called during static
# analysis. As such, we'll just return null.
-func _look_up_object_by_global_id(global_id: String):
+func _look_up_object_by_global_id(_global_id: String):
return null
@@ -305,8 +304,7 @@ func look_up_variable(name: ESCToken, expr: ESCGrammarExpr):
if distance == -1:
return _globals.get_value(name)
- else:
- return _environment.get_at(distance, name.get_lexeme())
+ return _environment.get_at(distance, name.get_lexeme())
func _evaluate(expr: ESCGrammarExpr):
diff --git a/addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd b/addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd
index 2447a87..f074e6e 100644
--- a/addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd
+++ b/addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd
@@ -1,6 +1,6 @@
@tool
-extends Node
class_name ESCAshesAnalyzer
+extends Node
# TODO: Update this when we move from the .esc extension for Escoria scripts to .ash
diff --git a/addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd b/addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd
index 53aa7a9..b06bd78 100644
--- a/addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd
+++ b/addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd
@@ -1,5 +1,5 @@
-extends RefCounted
class_name ESCStaticAnalyzers
+extends RefCounted
var _analyzers = [
diff --git a/addons/escoria-core/ui_library/fonts/caslonantique.ttf b/addons/escoria-core/ui_library/fonts/caslonantique.ttf
index eec6f63..eec6f63 100644..100755
--- a/addons/escoria-core/ui_library/fonts/caslonantique.ttf
+++ b/addons/escoria-core/ui_library/fonts/caslonantique.ttf
Binary files differ
diff --git a/addons/escoria-core/ui_library/inventory/esc_inventory_button.gd b/addons/escoria-core/ui_library/inventory/esc_inventory_button.gd
index b109c38..390055f 100644
--- a/addons/escoria-core/ui_library/inventory/esc_inventory_button.gd
+++ b/addons/escoria-core/ui_library/inventory/esc_inventory_button.gd
@@ -1,7 +1,7 @@
## The inventory representation of an ESC item if pickable (only used by
## the inventory components)
-extends TextureButton
class_name ESCInventoryButton
+extends TextureButton
## Signal emitted when the item was left clicked.[br]
## [br]
diff --git a/addons/escoria-core/ui_library/inventory/esc_inventory_container.gd b/addons/escoria-core/ui_library/inventory/esc_inventory_container.gd
index 1e6dab8..748d714 100644
--- a/addons/escoria-core/ui_library/inventory/esc_inventory_container.gd
+++ b/addons/escoria-core/ui_library/inventory/esc_inventory_container.gd
@@ -1,7 +1,7 @@
## Inventory container handler that acts as a base for UIs inventory containers.
-extends Control
class_name ESCInventoryContainer
+extends Control
## Whether any item in the container is currently focused.[br]
var item_focused: bool = false
@@ -85,7 +85,7 @@ func get_inventory_button(inventory_item: ESCInventoryItem) -> ESCInventoryButto
## #### Returns[br]
## [br]
## Returns nothing.
-func _on_inventory_item_focused(item_id):
+func _on_inventory_item_focused(_item_id):
item_focused = true
diff --git a/addons/escoria-core/ui_library/menus/load_save/save/save_game.gd b/addons/escoria-core/ui_library/menus/load_save/save/save_game.gd
index 33bbb13..ad7e1ca 100644
--- a/addons/escoria-core/ui_library/menus/load_save/save/save_game.gd
+++ b/addons/escoria-core/ui_library/menus/load_save/save/save_game.gd
@@ -29,9 +29,10 @@ func _on_slot_pressed(p_slot_n: int):
# Create the slots from the list of savegames
func refresh_savegames():
- var _slots = $VBoxContainer/ScrollContainer/slots
- for slot in _slots.get_children():
- _slots.remove_child(slot)
+ var slots = $VBoxContainer/ScrollContainer/slots
+ for slot in slots.get_children():
+ slots.remove_child(slot)
+ slot.queue_free()
var saves_list = escoria.save_manager.get_saves_list()
@@ -44,8 +45,8 @@ func refresh_savegames():
datetime["minute"],
]
var new_slot = slot_ui_scene.instantiate()
- _slots.add_child(new_slot)
- _slots.move_child(new_slot, 0)
+ slots.add_child(new_slot)
+ slots.move_child(new_slot, 0)
new_slot.set_slot_name_date(tr("New save"), datetime_string)
new_slot.connect("pressed", Callable(self, "_on_slot_pressed").bind(saves_list.size()+1))
@@ -56,7 +57,7 @@ func refresh_savegames():
for save in saves_array:
new_slot = slot_ui_scene.instantiate()
- _slots.add_child(new_slot)
+ slots.add_child(new_slot)
datetime_string = "%02d/%02d/%02d %02d:%02d" % [
save.date["day"],
diff --git a/addons/escoria-core/ui_library/menus/load_save/save/save_name_popup.gd b/addons/escoria-core/ui_library/menus/load_save/save/save_name_popup.gd
index b631ef2..00ac81f 100644
--- a/addons/escoria-core/ui_library/menus/load_save/save/save_name_popup.gd
+++ b/addons/escoria-core/ui_library/menus/load_save/save/save_name_popup.gd
@@ -19,3 +19,7 @@ func _input(event):
and event.keycode == KEY_ENTER \
or event.keycode == KEY_KP_ENTER:
_on_ok_pressed()
+
+
+func _on_about_to_popup() -> void:
+ $MarginContainer/VBoxContainer/LineEdit.grab_focus()
diff --git a/addons/escoria-core/ui_library/menus/load_save/save/save_name_popup.tscn b/addons/escoria-core/ui_library/menus/load_save/save/save_name_popup.tscn
index 8d6fc57..5bfb606 100644
--- a/addons/escoria-core/ui_library/menus/load_save/save/save_name_popup.tscn
+++ b/addons/escoria-core/ui_library/menus/load_save/save/save_name_popup.tscn
@@ -1,71 +1,46 @@
-[gd_scene load_steps=2 format=3 uid="uid://coiqm0xcq8f8o"]
+[gd_scene format=3 uid="uid://coiqm0xcq8f8o"]
[ext_resource type="Script" uid="uid://blqbjhwtenth5" path="res://addons/escoria-core/ui_library/menus/load_save/save/save_name_popup.gd" id="2"]
-[node name="save_name_popup" type="Popup"]
-anchor_right = 1.0
-anchor_bottom = 1.0
+[node name="save_name_popup" type="Popup" unique_id=1050292675]
+oversampling_override = 1.0
+size = Vector2i(209, 185)
script = ExtResource("2")
-__meta__ = {
-"_edit_use_anchors_": false
-}
-[node name="MarginContainer" type="MarginContainer" parent="."]
+[node name="MarginContainer" type="MarginContainer" parent="." unique_id=410184148]
+anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
-theme_override_constants/margin_right = 30
-theme_override_constants/margin_top = 30
theme_override_constants/margin_left = 30
+theme_override_constants/margin_top = 30
+theme_override_constants/margin_right = 30
theme_override_constants/margin_bottom = 30
-__meta__ = {
-"_edit_use_anchors_": false
-}
-[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
-offset_left = 30.0
-offset_top = 401.0
-offset_right = 1250.0
-offset_bottom = 499.0
+[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer" unique_id=440596471]
+layout_mode = 2
size_flags_vertical = 4
theme_override_constants/separation = 20
-[node name="Label" type="Label" parent="MarginContainer/VBoxContainer"]
-offset_right = 1220.0
-offset_bottom = 14.0
+[node name="Label" type="Label" parent="MarginContainer/VBoxContainer" unique_id=2007386692]
+layout_mode = 2
text = "ENTER_SAVE_NAME"
-[node name="LineEdit" type="LineEdit" parent="MarginContainer/VBoxContainer"]
-offset_top = 34.0
-offset_right = 1220.0
-offset_bottom = 58.0
+[node name="LineEdit" type="LineEdit" parent="MarginContainer/VBoxContainer" unique_id=1605064251]
+layout_mode = 2
-[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
-offset_top = 78.0
-offset_right = 1220.0
-offset_bottom = 98.0
+[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=1175843435]
+layout_mode = 2
theme_override_constants/separation = 10
alignment = 2
-__meta__ = {
-"_edit_use_anchors_": false
-}
-[node name="cancel" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer"]
-offset_left = 1118.0
-offset_right = 1179.0
-offset_bottom = 20.0
+[node name="cancel" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer" unique_id=1190606511]
+layout_mode = 2
text = "CANCEL"
-__meta__ = {
-"_edit_use_anchors_": false
-}
-[node name="ok" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer"]
-offset_left = 1189.0
-offset_right = 1220.0
-offset_bottom = 20.0
+[node name="ok" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer" unique_id=2090364237]
+layout_mode = 2
text = "OK"
-__meta__ = {
-"_edit_use_anchors_": false
-}
+[connection signal="about_to_popup" from="." to="." method="_on_about_to_popup"]
[connection signal="pressed" from="MarginContainer/VBoxContainer/HBoxContainer/cancel" to="." method="_on_cancel_pressed"]
[connection signal="pressed" from="MarginContainer/VBoxContainer/HBoxContainer/ok" to="." method="_on_ok_pressed"]
diff --git a/addons/escoria-core/ui_library/menus/options/options.gd b/addons/escoria-core/ui_library/menus/options/options.gd
index 32a9758..7b270fc 100644
--- a/addons/escoria-core/ui_library/menus/options/options.gd
+++ b/addons/escoria-core/ui_library/menus/options/options.gd
@@ -24,24 +24,24 @@ var _loaded_languages: Array = []
# Initialize the flags
func _ready() -> void:
- var _flags_container: HBoxContainer = \
+ var flags_container: HBoxContainer = \
$VBoxContainer/MarginContainer/options/flags
- for child in _flags_container.get_children():
- _flags_container.remove_child(child)
+ for child in flags_container.get_children():
+ flags_container.remove_child(child)
child.queue_free()
_loaded_languages = []
- for lang in TranslationServer.get_loaded_locales():
- if not lang in _loaded_languages:
- _loaded_languages.append(lang)
- var _lang = TextureRect.new()
- _lang.texture = load(
+ for language in TranslationServer.get_loaded_locales():
+ if not language in _loaded_languages:
+ _loaded_languages.append(language)
+ var language_item = TextureRect.new()
+ language_item.texture = load(
"res://addons/escoria-core/ui_library" + \
- "/menus/options/flags/%s.png" % lang
+ "/menus/options/flags/%s.png" % language
)
- _flags_container.add_child(_lang)
- _lang.connect("gui_input", Callable(self, "_on_language_input").bind(lang))
+ flags_container.add_child(language_item)
+ language_item.connect("gui_input", Callable(self, "_on_language_input").bind(language))
# Show the options
@@ -56,13 +56,13 @@ func show():
# #### Parameters
# - p_settings: The settings to use
func initialize_options(p_settings):
- var _options = $VBoxContainer/MarginContainer/options
- _options.get_node("general_volume").value = p_settings["master_volume"]
- _options.get_node("sound_volume").value = p_settings["sfx_volume"]
- _options.get_node("music_volume").value = p_settings["music_volume"]
- _options.get_node("speech_volume").value = p_settings["speech_volume"]
- _options.get_node("ambient_volume").value = p_settings["ambient_volume"]
- _options.get_node("fullscreen").set_pressed_no_signal(p_settings["fullscreen"])
+ var options = $VBoxContainer/MarginContainer/options
+ options.get_node("general_volume").value = p_settings["master_volume"]
+ options.get_node("sound_volume").value = p_settings["sfx_volume"]
+ options.get_node("music_volume").value = p_settings["music_volume"]
+ options.get_node("speech_volume").value = p_settings["speech_volume"]
+ options.get_node("ambient_volume").value = p_settings["ambient_volume"]
+ options.get_node("fullscreen").set_pressed_no_signal(p_settings["fullscreen"])
# The language was changed
diff --git a/addons/escoria-dialog-simple/chooser/simple.gd b/addons/escoria-dialog-simple/chooser/simple.gd
index d7c0297..6fc5ad5 100644
--- a/addons/escoria-dialog-simple/chooser/simple.gd
+++ b/addons/escoria-dialog-simple/chooser/simple.gd
@@ -17,7 +17,7 @@ func _ready() -> void:
# Process the timeout display
-func _process(delta: float) -> void:
+func _process(_delta: float) -> void:
if $MarginContainer.visible and self.dialog and self.dialog.timeout > 0:
$TimerProgress.value = (
self.dialog.timeout - $Timer.time_left
@@ -26,28 +26,28 @@ func _process(delta: float) -> void:
# Show the chooser
func show_chooser():
- var _vbox = $MarginContainer/ScrollContainer/VBoxContainer
- for option_node in _vbox.get_children():
- _vbox.remove_child(option_node)
+ var vbox = $MarginContainer/ScrollContainer/VBoxContainer
+ for option_node in vbox.get_children():
+ vbox.remove_child(option_node)
_remove_avatar()
for option in self.dialog.options:
if option.is_valid():
- var _option_node = Button.new()
- _option_node.text = (option as ESCDialogOption).option
- _option_node.flat = true
- _option_node.add_theme_color_override("font_color", color_normal)
- _option_node.add_theme_color_override("font_color_hover", color_hover)
- _vbox.add_child(_option_node)
+ var option_node = Button.new()
+ option_node.text = (option as ESCDialogOption).option
+ option_node.flat = true
+ option_node.add_theme_color_override("font_color", color_normal)
+ option_node.add_theme_color_override("font_color_hover", color_hover)
+ vbox.add_child(option_node)
- _option_node.pressed.connect(_on_answer_selected.bind(option))
+ option_node.pressed.connect(_on_answer_selected.bind(option))
# If we've no options left, signify as much and start the timer with a
# very short interval so the appropriate signal can be fired. Note that
# we have to fire the signal AFTER this method returns as the caller
# is almost certainly yielding after this method returns.
- if _vbox.get_child_count() == 0:
+ if vbox.get_child_count() == 0:
_no_more_options = true
$Timer.start(0.05)
return
@@ -59,8 +59,10 @@ func show_chooser():
$MarginContainer.show()
- if self.dialog.timeout > 0:
+ if self.dialog.timeout > 0 and self.dialog.get_timeout_option():
$Timer.start(self.dialog.timeout)
+ else:
+ $TimerProgress.value = 0
# Hide the chooser
@@ -88,7 +90,7 @@ func _on_answer_selected(option: ESCDialogOption):
# The timeout came and a option was selected
func _on_Timer_timeout() -> void:
- var option_chosen = null if _no_more_options else self.dialog.options[self.dialog.timeout_option - 1]
+ var option_chosen = null if _no_more_options else self.dialog.get_timeout_option()
_no_more_options = false
_option_chosen(option_chosen)
diff --git a/addons/escoria-dialog-simple/esc_dialog_simple_state_machine.gd b/addons/escoria-dialog-simple/esc_dialog_simple_state_machine.gd
index 4c48f60..bf26c26 100644
--- a/addons/escoria-dialog-simple/esc_dialog_simple_state_machine.gd
+++ b/addons/escoria-dialog-simple/esc_dialog_simple_state_machine.gd
@@ -10,8 +10,8 @@ func _init():
# dialogs state machine. Since Escoria can't guess which state is the default one
# it has to be assigned here. If it happens to be null on initialize() call,
# an error is triggered.
- START_STATE = states_map[current_state_name]
- initialize(START_STATE)
+ start_state = states_map[current_state_name]
+ initialize(start_state)
# Creates the states for this state machine.
diff --git a/addons/escoria-ui-keyboard-9verbs/fonts/caslonantique.ttf b/addons/escoria-ui-keyboard-9verbs/fonts/caslonantique.ttf
index eec6f63..eec6f63 100644..100755
--- a/addons/escoria-ui-keyboard-9verbs/fonts/caslonantique.ttf
+++ b/addons/escoria-ui-keyboard-9verbs/fonts/caslonantique.ttf
Binary files differ
diff --git a/addons/escoria-ui-keyboard-9verbs/game.gd b/addons/escoria-ui-keyboard-9verbs/game.gd
index 0e55973..e1b80fc 100644
--- a/addons/escoria-ui-keyboard-9verbs/game.gd
+++ b/addons/escoria-ui-keyboard-9verbs/game.gd
@@ -1,17 +1,6 @@
extends ESCGame
-const VERB_CLOSE = "close"
-const VERB_GIVE = "give"
-const VERB_LOOK = "look"
-const VERB_OPEN = "open"
-const VERB_PICKUP = "pickup"
-const VERB_PULL = "pull"
-const VERB_PUSH = "push"
-const VERB_TALK = "talk"
-const VERB_USE = "use"
-
-
"""
Implement methods to react to inputs.
@@ -49,6 +38,17 @@ Implement methods to react to inputs.
- _on_event_done(event_name: String)
"""
+const VERB_CLOSE = "close"
+const VERB_GIVE = "give"
+const VERB_LOOK = "look"
+const VERB_OPEN = "open"
+const VERB_PICKUP = "pickup"
+const VERB_PULL = "pull"
+const VERB_PUSH = "push"
+const VERB_TALK = "talk"
+const VERB_USE = "use"
+
+const INPUT_MAP = preload("res://addons/escoria-ui-keyboard-9verbs/input_map.gd")
@onready var verbs_menu = $ui/Control/panel_down/VBoxContainer/HBoxContainer\
/VerbsMargin/verbs_menu
@@ -58,7 +58,6 @@ Implement methods to react to inputs.
/MainMargin/VBoxContainer/room_select
@onready var inventory_ui = $ui/Control/panel_down/VBoxContainer/HBoxContainer\
/InventoryMargin/inventory_ui
-const input_map = preload("res://addons/escoria-ui-keyboard-9verbs/input_map.gd")
func _enter_tree():
var room_selector_parent = $ui/Control/panel_down/VBoxContainer\
@@ -75,46 +74,45 @@ func _enter_tree():
var input_handler = Callable(self, "_process_input")
escoria.inputs_manager.register_custom_input_handler(input_handler)
- input_map.add_actions_to_input_map()
+ INPUT_MAP.add_actions_to_input_map()
func _exit_tree():
escoria.inputs_manager.register_custom_input_handler(null)
- input_map.erase_actions_from_input_map()
+ INPUT_MAP.erase_actions_from_input_map()
func _process_input(event: InputEvent, is_default_state: bool) -> bool:
if not is_default_state:
return false
- elif event.is_action_pressed(input_map.ACTION_SET_VERB_OPEN):
+ if event.is_action_pressed(INPUT_MAP.ACTION_SET_VERB_OPEN):
verbs_menu.on_action_selected(VERB_OPEN)
return true
- elif event.is_action_pressed(input_map.ACTION_SET_VERB_PICKUP):
+ if event.is_action_pressed(INPUT_MAP.ACTION_SET_VERB_PICKUP):
verbs_menu.on_action_selected(VERB_PICKUP)
return true
- elif event.is_action_pressed(input_map.ACTION_SET_VERB_PUSH):
+ if event.is_action_pressed(INPUT_MAP.ACTION_SET_VERB_PUSH):
verbs_menu.on_action_selected(VERB_PUSH)
return true
- elif event.is_action_pressed(input_map.ACTION_SET_VERB_CLOSE):
+ if event.is_action_pressed(INPUT_MAP.ACTION_SET_VERB_CLOSE):
verbs_menu.on_action_selected(VERB_CLOSE)
return true
- elif event.is_action_pressed(input_map.ACTION_SET_VERB_LOOK):
+ if event.is_action_pressed(INPUT_MAP.ACTION_SET_VERB_LOOK):
verbs_menu.on_action_selected(VERB_LOOK)
return true
- elif event.is_action_pressed(input_map.ACTION_SET_VERB_PULL):
+ if event.is_action_pressed(INPUT_MAP.ACTION_SET_VERB_PULL):
verbs_menu.on_action_selected(VERB_PULL)
return true
- elif event.is_action_pressed(input_map.ACTION_SET_VERB_GIVE):
+ if event.is_action_pressed(INPUT_MAP.ACTION_SET_VERB_GIVE):
verbs_menu.on_action_selected(VERB_GIVE)
return true
- elif event.is_action_pressed(input_map.ACTION_SET_VERB_USE):
+ if event.is_action_pressed(INPUT_MAP.ACTION_SET_VERB_USE):
verbs_menu.on_action_selected(VERB_USE)
return true
- elif event.is_action_pressed(input_map.ACTION_SET_VERB_TALK):
+ if event.is_action_pressed(INPUT_MAP.ACTION_SET_VERB_TALK):
verbs_menu.on_action_selected(VERB_TALK)
return true
- else:
- return false
+ return false
## BACKGROUND ##
@@ -166,14 +164,14 @@ func element_focused(element_id: String) -> void:
# (ie verb+item(+target)) because the action is now being executed
# and the tooltip is already set because the item was focused
# (see element_focused() and inventory_item_focused())
- ESCActionManager.ACTION_INPUT_STATE.COMPLETED:
+ ESCActionManager.ActionInputState.COMPLETED:
return
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_VERB_OR_ITEM, \
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_ITEM:
+ ESCActionManager.ActionInputState.AWAITING_VERB_OR_ITEM, \
+ ESCActionManager.ActionInputState.AWAITING_ITEM:
tooltip.set_target(target_obj.tooltip_name)
-
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_TARGET_ITEM:
+ verbs_menu.set_by_name(target_obj.default_action)
+ ESCActionManager.ActionInputState.AWAITING_TARGET_ITEM:
tooltip.set_target2(target_obj.tooltip_name)
@@ -183,15 +181,15 @@ func element_unfocused() -> void:
# (ie verb+item(+target)) because the action is now being executed
# and the tooltip is already set because the item was focused
# (see element_focused() and inventory_item_focused())
- ESCActionManager.ACTION_INPUT_STATE.COMPLETED:
+ ESCActionManager.ActionInputState.COMPLETED:
return
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_VERB_OR_ITEM, \
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_ITEM:
+ ESCActionManager.ActionInputState.AWAITING_VERB_OR_ITEM, \
+ ESCActionManager.ActionInputState.AWAITING_ITEM:
tooltip.set_target("")
verbs_menu.unselect_actions()
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_TARGET_ITEM:
+ ESCActionManager.ActionInputState.AWAITING_TARGET_ITEM:
tooltip.set_target2("")
@@ -213,23 +211,34 @@ func left_click_on_item(item_global_id: String, event: InputEvent) -> void:
# (ie verb+item(+target)) because the action is now being executed
# and the tooltip is already set because the item was focused
# (see element_focused() and inventory_item_focused())
- ESCActionManager.ACTION_INPUT_STATE.COMPLETED:
+ ESCActionManager.ActionInputState.COMPLETED:
return
# Just clicked on the item
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_VERB_OR_ITEM, \
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_ITEM:
+ ESCActionManager.ActionInputState.AWAITING_VERB_OR_ITEM, \
+ ESCActionManager.ActionInputState.AWAITING_ITEM:
tooltip.set_target(target_obj.tooltip_name)
# Clicked on item and now we're awaiting a target item
# This means we clicked the tool and we now need a target
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_TARGET_ITEM:
+ ESCActionManager.ActionInputState.AWAITING_TARGET_ITEM:
tooltip.set_target(target_obj.tooltip_name, true)
func right_click_on_item(item_global_id: String, event: InputEvent) -> void:
- escoria.action_manager.set_current_action(verbs_menu.selected_action)
+ var action_verb: String = escoria.action_manager.current_action
+ if action_verb.is_empty():
+ action_verb = verbs_menu.selected_action
+ if action_verb.is_empty():
+ action_verb = escoria.object_manager.get_object(item_global_id).node.default_action
+ if action_verb.is_empty():
+ escoria.logger.warn(
+ self,
+ "Right click event: No default action verb defined for item %s. Aborting." % [item_global_id]
+ )
+ return
+ escoria.action_manager.set_current_action(action_verb)
escoria.action_manager.do(
escoria.action_manager.ACTION.ITEM_RIGHT_CLICK,
[item_global_id, event],
@@ -261,17 +270,17 @@ func left_click_on_inventory_item(inventory_item_global_id: String, event: Input
# (ie verb+item(+target)) because the action is now being executed
# and the tooltip is already set because the item was focused
# (see element_focused() and inventory_item_focused())
- ESCActionManager.ACTION_INPUT_STATE.COMPLETED:
+ ESCActionManager.ActionInputState.COMPLETED:
return
# Just clicked on the inventory item: do nothing special
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_VERB_OR_ITEM, \
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_ITEM:
+ ESCActionManager.ActionInputState.AWAITING_VERB_OR_ITEM, \
+ ESCActionManager.ActionInputState.AWAITING_ITEM:
return
# Clicked on inventory item and now we're awaiting a target item
# This means we clicked the tool and we now need a target
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_TARGET_ITEM:
+ ESCActionManager.ActionInputState.AWAITING_TARGET_ITEM:
tooltip.set_target(target_obj.tooltip_name, true)
@@ -297,35 +306,35 @@ func inventory_item_focused(inventory_item_global_id: String) -> void:
# (ie verb+item(+target)) because the action is now being executed
# and the tooltip is already set because the item was focused
# (see element_focused() and inventory_item_focused())
- ESCActionManager.ACTION_INPUT_STATE.COMPLETED:
+ ESCActionManager.ActionInputState.COMPLETED:
return
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_VERB_OR_ITEM, \
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_ITEM:
+ ESCActionManager.ActionInputState.AWAITING_VERB_OR_ITEM, \
+ ESCActionManager.ActionInputState.AWAITING_ITEM:
tooltip.set_target(target_obj.tooltip_name)
# Hovering an ESCItem highlights its default action
if escoria.action_manager.current_action != VERB_USE and target_obj is ESCItem:
verbs_menu.set_by_name(target_obj.default_action)
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_TARGET_ITEM:
+ ESCActionManager.ActionInputState.AWAITING_TARGET_ITEM:
tooltip.set_target2(target_obj.tooltip_name)
func inventory_item_unfocused() -> void:
match escoria.action_manager.action_state:
- ESCActionManager.ACTION_INPUT_STATE.COMPLETED:
+ ESCActionManager.ActionInputState.COMPLETED:
# Don't change the tooltip if an action input is completed
# (ie verb+item(+target)) because the action is now being executed
return
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_VERB_OR_ITEM, \
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_ITEM:
+ ESCActionManager.ActionInputState.AWAITING_VERB_OR_ITEM, \
+ ESCActionManager.ActionInputState.AWAITING_ITEM:
tooltip.set_target("")
verbs_menu.unselect_actions()
- ESCActionManager.ACTION_INPUT_STATE.AWAITING_TARGET_ITEM:
+ ESCActionManager.ActionInputState.AWAITING_TARGET_ITEM:
tooltip.set_target2("")
diff --git a/addons/escoria-ui-keyboard-9verbs/verbs_menu.gd b/addons/escoria-ui-keyboard-9verbs/verbs_menu.gd
index 22c417c..224271a 100644
--- a/addons/escoria-ui-keyboard-9verbs/verbs_menu.gd
+++ b/addons/escoria-ui-keyboard-9verbs/verbs_menu.gd
@@ -5,7 +5,7 @@ This script is out of Escoria's scope. It controls the UI reaction to an
UI event (eg right click) to change the cursor accordingly.
"""
-var selected_action
+var selected_action: String
func _ready():
for but in get_children():
diff --git a/addons/escoria-ui-simplemouse/cursors/new/wait_b.png b/addons/escoria-ui-simplemouse/cursors/new/wait_b.png
new file mode 100644
index 0000000..66d213f
--- /dev/null
+++ b/addons/escoria-ui-simplemouse/cursors/new/wait_b.png
Binary files differ
diff --git a/addons/escoria-ui-simplemouse/cursors/new/wait_b.png.import b/addons/escoria-ui-simplemouse/cursors/new/wait_b.png.import
new file mode 100644
index 0000000..26f990c
--- /dev/null
+++ b/addons/escoria-ui-simplemouse/cursors/new/wait_b.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cm0t24grofr0q"
+path="res://.godot/imported/wait_b.png-5fe9f321b769de8f337c3f27efa2752e.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/escoria-ui-simplemouse/cursors/new/wait_b.png"
+dest_files=["res://.godot/imported/wait_b.png-5fe9f321b769de8f337c3f27efa2752e.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/escoria-ui-simplemouse/fonts/caslonantique.ttf b/addons/escoria-ui-simplemouse/fonts/caslonantique.ttf
index eec6f63..eec6f63 100644..100755
--- a/addons/escoria-ui-simplemouse/fonts/caslonantique.ttf
+++ b/addons/escoria-ui-simplemouse/fonts/caslonantique.ttf
Binary files differ
diff --git a/addons/escoria-ui-simplemouse/game.gd b/addons/escoria-ui-simplemouse/game.gd
index f18444d..4828862 100644
--- a/addons/escoria-ui-simplemouse/game.gd
+++ b/addons/escoria-ui-simplemouse/game.gd
@@ -1,10 +1,5 @@
extends ESCGame
-
-const VERB_USE = "use"
-const VERB_WALK = "walk"
-
-
"""
Implement methods to react to inputs.
@@ -42,6 +37,9 @@ Implement methods to react to inputs.
- _on_event_done(event_name: String)
"""
+const VERB_USE = "use"
+const VERB_WALK = "walk"
+
# Value to use for `device` argument to various `Input.get_joy` methods.
const JOY_DEVICE = 0
@@ -64,18 +62,20 @@ const CHANGE_VERB_BUTTON = JOY_BUTTON_Y
# Input action for use by InputMap
const ESC_UI_CHANGE_VERB_ACTION = "esc_change_verb"
+var targeted_node: Node
+
# true when a gamepad is connected.
var _is_gamepad_connected = false
# Tracks the mouse's current position onscreen.
var _current_mouse_pos: Vector2 = Vector2.ZERO
-var targeted_node: Node
-
func _ready():
hide_ui()
$ui/tooltip.connect("tooltip_size_updated", Callable(self, "update_tooltip_following_mouse_position"))
+ escoria.inputs_manager.input_mode_changed.connect(_on_input_mode_changed)
+
func _enter_tree():
initialize_esc_game()
@@ -148,10 +148,13 @@ func _on_gamepad_disconnected():
func _on_joy_connection_changed(device: int, connected: bool) -> void:
if device != JOY_DEVICE:
return
- elif connected:
+
+ _is_gamepad_connected = connected
+ if connected:
_on_gamepad_connected()
- else:
- _on_gamepad_disconnected()
+ return
+
+ _on_gamepad_disconnected()
func _process(_delta) -> void:
@@ -176,16 +179,18 @@ func _process_input(event: InputEvent, is_default_state: bool) -> bool:
# ESCBackground is not guaranteed to be set, as we may be on
# the "New Game" screen.
return false
- elif _is_gamepad_connected and event is InputEventJoypadButton:
+
+ if _is_gamepad_connected and event is InputEventJoypadButton:
escoria.logger.trace(self, "InputEventJoypadButton: %s" % [event.as_text()])
if event.is_action_pressed(escoria.inputs_manager.ESC_UI_PRIMARY_ACTION):
# Admittedly, this breaks abstraction barriers and is completely
# inappropriate, but it's what works right now.
escoria.inputs_manager._on_left_click_on_bg(get_global_mouse_position())
return true
- elif event.is_action_pressed(ESC_UI_CHANGE_VERB_ACTION):
- mousewheel_action(1)
- return true
+
+ if event.is_action_pressed(ESC_UI_CHANGE_VERB_ACTION):
+ mousewheel_action(1)
+ return true
return false
@@ -335,7 +340,7 @@ func right_click_on_inventory_item(inventory_item_global_id: String, event: Inpu
)
-func left_double_click_on_inventory_item(inventory_item_global_id: String, event: InputEvent) -> void:
+func left_double_click_on_inventory_item(_inventory_item_global_id: String, _event: InputEvent) -> void:
pass
@@ -359,6 +364,14 @@ func inventory_item_unfocused() -> void:
$mouse_layer/verbs_menu.set_by_name(VERB_WALK)
+func open_inventory():
+ $ui/inventory_ui.show_inventory()
+
+
+func close_inventory():
+ $ui/inventory_ui.hide_inventory()
+
+
func mousewheel_action(direction: int):
$mouse_layer/verbs_menu.iterate_actions_cursor(direction)
@@ -379,22 +392,26 @@ func show_ui():
func hide_main_menu():
show_ui()
- escoria.current_state = escoria.GAME_STATE.DEFAULT
+ $dialog_layer.propagate_call("set_visible", [true], true)
+ if escoria.current_state != escoria.GameState.LOADING:
+ escoria.current_state = escoria.GameState.DEFAULT
if get_node(main_menu).visible:
get_node(main_menu).hide()
func show_main_menu():
- if escoria.current_state == escoria.GAME_STATE.PAUSED:
+ if escoria.current_state == escoria.GameState.PAUSED:
return
- escoria.current_state = escoria.GAME_STATE.PAUSED
+ escoria.current_state = escoria.GameState.PAUSED
hide_ui()
+ $dialog_layer.propagate_call("set_visible", [false], true)
if not get_node(main_menu).visible:
get_node(main_menu).reset()
get_node(main_menu).show()
func unpause_game():
show_ui()
- escoria.current_state = escoria.GAME_STATE.DEFAULT
+ $dialog_layer.propagate_call("set_visible", [true], true)
+ escoria.current_state = escoria.GameState.DEFAULT
if get_node(pause_menu).visible:
get_node(pause_menu).hide()
escoria.object_manager.get_object(ESCObjectManager.SPEECH).node.resume()
@@ -403,10 +420,11 @@ func unpause_game():
escoria.set_game_paused(false)
func pause_game():
- if escoria.current_state == escoria.GAME_STATE.PAUSED:
+ if escoria.current_state == escoria.GameState.PAUSED:
return
- show_ui()
- escoria.current_state = escoria.GAME_STATE.PAUSED
+ hide_ui()
+ $dialog_layer.propagate_call("set_visible", [false], true)
+ escoria.current_state = escoria.GameState.PAUSED
if not get_node(pause_menu).visible:
get_node(main_menu).reset()
get_node(pause_menu).reset()
@@ -475,3 +493,18 @@ func _on_event_done(_return_code: int, _event_name: String):
func _on_room_ready():
$mouse_layer/verbs_menu.set_by_name(VERB_WALK)
$mouse_layer/verbs_menu.action_manually_changed = false
+
+func _on_input_mode_changed(new_mode):
+ if new_mode == ESCInputsManager.INPUT_NONE:
+ $mouse_layer/verbs_menu.set_by_name("wait")
+ return
+ if (
+ escoria.action_manager.action_state
+ == ESCActionManager.ActionInputState.AWAITING_TARGET_ITEM
+ ):
+ return
+ $mouse_layer/verbs_menu.set_by_name(escoria.action_manager.current_action)
+
+
+func _on_MenuButton_pressed() -> void:
+ pause_game()
diff --git a/addons/escoria-ui-simplemouse/game.tscn b/addons/escoria-ui-simplemouse/game.tscn
index a36efc2..6897c14 100644
--- a/addons/escoria-ui-simplemouse/game.tscn
+++ b/addons/escoria-ui-simplemouse/game.tscn
@@ -1,37 +1,34 @@
[gd_scene format=3 uid="uid://u646id5k2hvg"]
+[ext_resource type="PackedScene" uid="uid://ciy2hn83a3rim" path="res://addons/escoria-ui-simplemouse/inventory/inventory_ui.tscn" id="1"]
[ext_resource type="Script" uid="uid://dfl7khtlretr7" path="res://addons/escoria-core/game/scenes/dialogs/esc_dialog_player.gd" id="2"]
-[ext_resource type="PackedScene" uid="uid://dmw5gicuenj53" path="res://addons/escoria-core/game/scenes/camera_player/camera.tscn" id="3"]
[ext_resource type="PackedScene" uid="uid://0qms6fx5siad" path="res://addons/escoria-ui-simplemouse/verbs_mouseicons.tscn" id="4"]
[ext_resource type="Script" uid="uid://dqrptsg8qv2o7" path="res://addons/escoria-ui-simplemouse/game.gd" id="5"]
[ext_resource type="PackedScene" uid="uid://b3hru28v8y36i" path="res://addons/escoria-ui-simplemouse/tooltip/target_tooltip.tscn" id="6"]
[ext_resource type="PackedScene" uid="uid://u8an5av5fuxx" path="res://addons/escoria-core/ui_library/menus/main_menu/main_menu.tscn" id="7"]
-[ext_resource type="PackedScene" uid="uid://d0dgknfues04m" path="res://game/ui/inventory.tscn" id="7_2mv1l"]
[ext_resource type="PackedScene" uid="uid://gfjgmbcafyyt" path="res://addons/escoria-core/ui_library/menus/pause_menu/pause_menu.tscn" id="8"]
[ext_resource type="FontFile" path="res://addons/escoria-ui-simplemouse/fonts/caslonantique.tres" id="8_c4xbd"]
[ext_resource type="Theme" uid="uid://cujvdn6gu1j74" path="res://addons/escoria-ui-simplemouse/theme.tres" id="9"]
-[node name="game" type="Node2D" unique_id=786292577]
+[node name="game" type="Node2D" unique_id=1682411223]
script = ExtResource("5")
main_menu = NodePath("ui/main_menu")
pause_menu = NodePath("ui/pause_menu")
editor_debug_mode = 1
-[node name="camera" parent="." unique_id=1900634094 instance=ExtResource("3")]
-
-[node name="dialog_layer" type="CanvasLayer" parent="." unique_id=463489275]
+[node name="dialog_layer" type="CanvasLayer" parent="." unique_id=57427122]
layer = 3
-[node name="ESCDialogPlayer" type="Control" parent="dialog_layer" unique_id=341263763]
+[node name="ESCDialogPlayer" type="Control" parent="dialog_layer" unique_id=276945588]
layout_mode = 3
anchors_preset = 0
theme = ExtResource("9")
script = ExtResource("2")
metadata/_custom_type_script = "uid://dfl7khtlretr7"
-[node name="ui" type="CanvasLayer" parent="." unique_id=506331918]
+[node name="ui" type="CanvasLayer" parent="." unique_id=2005332761]
-[node name="tooltip" parent="ui" unique_id=1830597808 instance=ExtResource("6")]
+[node name="tooltip" parent="ui" unique_id=745625052 instance=ExtResource("6")]
offset_right = 1.0
mouse_filter = 2
theme_override_fonts/normal_font = ExtResource("8_c4xbd")
@@ -39,9 +36,9 @@ theme_override_font_sizes/normal_font_size = 21
text = "[center][color=#000000][/color][/center]"
fit_content = true
-[node name="inventory_ui" parent="ui" unique_id=792363245 instance=ExtResource("7_2mv1l")]
+[node name="inventory_ui" parent="ui" unique_id=52327563 instance=ExtResource("1")]
-[node name="HBoxContainer" type="HBoxContainer" parent="ui" unique_id=8246514]
+[node name="HBoxContainer" type="HBoxContainer" parent="ui" unique_id=1495159842]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
@@ -55,25 +52,25 @@ size_flags_horizontal = 3
size_flags_vertical = 3
mouse_filter = 2
-[node name="VBoxContainer" type="VBoxContainer" parent="ui/HBoxContainer" unique_id=669657222]
+[node name="VBoxContainer" type="VBoxContainer" parent="ui/HBoxContainer" unique_id=349862624]
layout_mode = 2
mouse_filter = 2
-[node name="MenuButton" type="Button" parent="ui/HBoxContainer/VBoxContainer" unique_id=878421894]
+[node name="MenuButton" type="Button" parent="ui/HBoxContainer/VBoxContainer" unique_id=1574178031]
layout_mode = 2
text = "Menu"
-[node name="pause_menu" parent="ui" unique_id=828994872 instance=ExtResource("8")]
+[node name="pause_menu" parent="ui" unique_id=2103269845 instance=ExtResource("8")]
visible = false
theme = ExtResource("9")
-[node name="main_menu" parent="ui" unique_id=1361358924 instance=ExtResource("7")]
+[node name="main_menu" parent="ui" unique_id=1468552475 instance=ExtResource("7")]
visible = false
-[node name="mouse_layer" type="CanvasLayer" parent="." unique_id=820408640]
+[node name="mouse_layer" type="CanvasLayer" parent="." unique_id=1934925264]
layer = 2
-[node name="verbs_menu" parent="mouse_layer" unique_id=50442657 instance=ExtResource("4")]
+[node name="verbs_menu" parent="mouse_layer" unique_id=847506105 instance=ExtResource("4")]
anchors_preset = 0
anchor_right = 0.0
anchor_bottom = 0.0
diff --git a/addons/escoria-ui-simplemouse/images/frame.png b/addons/escoria-ui-simplemouse/images/frame.png
index 2831220..2831220 100644..100755
--- a/addons/escoria-ui-simplemouse/images/frame.png
+++ b/addons/escoria-ui-simplemouse/images/frame.png
Binary files differ
diff --git a/addons/escoria-ui-simplemouse/images/inventory_bg.png b/addons/escoria-ui-simplemouse/images/inventory_bg.png
index 0efa8a0..0efa8a0 100644..100755
--- a/addons/escoria-ui-simplemouse/images/inventory_bg.png
+++ b/addons/escoria-ui-simplemouse/images/inventory_bg.png
Binary files differ
diff --git a/addons/escoria-ui-simplemouse/images/inventory_icon.png b/addons/escoria-ui-simplemouse/images/inventory_icon.png
index 7f8f637..7f8f637 100644..100755
--- a/addons/escoria-ui-simplemouse/images/inventory_icon.png
+++ b/addons/escoria-ui-simplemouse/images/inventory_icon.png
Binary files differ
diff --git a/addons/escoria-ui-simplemouse/verbs_mouseicons.gd b/addons/escoria-ui-simplemouse/verbs_mouseicons.gd
index 0413fe6..4787abe 100644
--- a/addons/escoria-ui-simplemouse/verbs_mouseicons.gd
+++ b/addons/escoria-ui-simplemouse/verbs_mouseicons.gd
@@ -13,7 +13,7 @@ func _ready():
set_by_name("walk")
set_process(false)
-func _process(delta):
+func _process(_delta):
$mouse_position.global_position = get_global_mouse_position()
diff --git a/addons/escoria-ui-simplemouse/verbs_mouseicons.tscn b/addons/escoria-ui-simplemouse/verbs_mouseicons.tscn
index aab2453..6d95822 100644
--- a/addons/escoria-ui-simplemouse/verbs_mouseicons.tscn
+++ b/addons/escoria-ui-simplemouse/verbs_mouseicons.tscn
@@ -1,15 +1,16 @@
-[gd_scene load_steps=9 format=3 uid="uid://0qms6fx5siad"]
+[gd_scene format=3 uid="uid://0qms6fx5siad"]
[ext_resource type="Script" uid="uid://d2mlyj6637kqo" path="res://addons/escoria-ui-simplemouse/verbs_mouseicons.gd" id="1"]
-[ext_resource type="Texture2D" uid="uid://dy1v07dsq0gv6" path="res://addons/escoria-ui-simplemouse/cursors/new/target_a.png" id="2_pg8p6"]
-[ext_resource type="Texture2D" uid="uid://kntfqvpgma13" path="res://addons/escoria-ui-simplemouse/cursors/new/look_b.png" id="3_iryjo"]
-[ext_resource type="Texture2D" uid="uid://2tj2uhyd8un1" path="res://addons/escoria-ui-simplemouse/cursors/new/hand_open.png" id="4_63dmd"]
-[ext_resource type="Texture2D" uid="uid://c6dy0a2st8l6k" path="res://addons/escoria-ui-simplemouse/cursors/new/tool_wrench.png" id="5_8bddv"]
-[ext_resource type="Texture2D" uid="uid://dsvbjwnblrs51" path="res://addons/escoria-ui-simplemouse/cursors/new/message_dots_round.png" id="6_u34a3"]
-[ext_resource type="Texture2D" uid="uid://bmah30wxobv25" path="res://addons/escoria-ui-simplemouse/cursors/new/arrow_w.png" id="7_p2p4e"]
-[ext_resource type="Texture2D" uid="uid://bm7mm8uhe2dey" path="res://addons/escoria-ui-simplemouse/cursors/new/arrow_e.png" id="8_5phyn"]
+[ext_resource type="Texture2D" uid="uid://bjhpqqbhp4ab1" path="res://addons/escoria-ui-simplemouse/cursors/new/target_a.png" id="2_pg8p6"]
+[ext_resource type="Texture2D" uid="uid://bki5oinr3dhfu" path="res://addons/escoria-ui-simplemouse/cursors/new/look_b.png" id="3_iryjo"]
+[ext_resource type="Texture2D" uid="uid://dqgt2k4sw4au2" path="res://addons/escoria-ui-simplemouse/cursors/new/hand_open.png" id="4_63dmd"]
+[ext_resource type="Texture2D" uid="uid://c8rnd6u3w72wu" path="res://addons/escoria-ui-simplemouse/cursors/new/tool_wrench.png" id="5_8bddv"]
+[ext_resource type="Texture2D" uid="uid://blycbkrub7pae" path="res://addons/escoria-ui-simplemouse/cursors/new/message_dots_round.png" id="6_u34a3"]
+[ext_resource type="Texture2D" uid="uid://bj476r0j38y5f" path="res://addons/escoria-ui-simplemouse/cursors/new/arrow_w.png" id="7_p2p4e"]
+[ext_resource type="Texture2D" uid="uid://b46kn0ifqhiu4" path="res://addons/escoria-ui-simplemouse/cursors/new/arrow_e.png" id="8_5phyn"]
+[ext_resource type="Texture2D" uid="uid://d4ljo7o1ji35p" path="res://addons/escoria-ui-simplemouse/cursors/new/wait_b.png" id="9_y6lk4"]
-[node name="verbs_menu" type="Control"]
+[node name="verbs_menu" type="Control" unique_id=464373944]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
@@ -18,7 +19,7 @@ grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")
-[node name="actions" type="HBoxContainer" parent="."]
+[node name="actions" type="HBoxContainer" parent="." unique_id=656813212]
visible = false
layout_mode = 1
anchors_preset = 15
@@ -33,53 +34,59 @@ grow_vertical = 2
size_flags_horizontal = 3
size_flags_vertical = 3
-[node name="walk" type="TextureRect" parent="actions"]
+[node name="walk" type="TextureRect" parent="actions" unique_id=1781415821]
layout_mode = 2
size_flags_horizontal = 6
size_flags_vertical = 6
texture = ExtResource("2_pg8p6")
-[node name="look" type="TextureRect" parent="actions"]
+[node name="look" type="TextureRect" parent="actions" unique_id=1355662703]
layout_mode = 2
size_flags_horizontal = 6
size_flags_vertical = 6
texture = ExtResource("3_iryjo")
-[node name="pickup" type="TextureRect" parent="actions"]
+[node name="pickup" type="TextureRect" parent="actions" unique_id=842854713]
layout_mode = 2
size_flags_horizontal = 6
size_flags_vertical = 6
texture = ExtResource("4_63dmd")
-[node name="use" type="TextureRect" parent="actions"]
+[node name="use" type="TextureRect" parent="actions" unique_id=2122094372]
layout_mode = 2
size_flags_horizontal = 6
size_flags_vertical = 6
texture = ExtResource("5_8bddv")
-[node name="talk" type="TextureRect" parent="actions"]
+[node name="talk" type="TextureRect" parent="actions" unique_id=1900178148]
layout_mode = 2
size_flags_horizontal = 6
size_flags_vertical = 6
texture = ExtResource("6_u34a3")
-[node name="exit_left" type="TextureRect" parent="actions"]
+[node name="exit_left" type="TextureRect" parent="actions" unique_id=1689145058]
layout_mode = 2
size_flags_horizontal = 6
size_flags_vertical = 6
texture = ExtResource("7_p2p4e")
-[node name="exit_right" type="TextureRect" parent="actions"]
+[node name="exit_right" type="TextureRect" parent="actions" unique_id=415377045]
layout_mode = 2
size_flags_horizontal = 6
size_flags_vertical = 6
texture = ExtResource("8_5phyn")
-[node name="mouse_position" type="Control" parent="."]
+[node name="wait" type="TextureRect" parent="actions" unique_id=1831818285]
+layout_mode = 2
+size_flags_horizontal = 6
+size_flags_vertical = 4
+texture = ExtResource("9_y6lk4")
+
+[node name="mouse_position" type="Control" parent="." unique_id=1277781179]
anchors_preset = 0
mouse_filter = 2
-[node name="tool" type="TextureRect" parent="mouse_position"]
+[node name="tool" type="TextureRect" parent="mouse_position" unique_id=865144765]
layout_mode = 0
offset_left = 48.0
offset_top = -16.0
diff --git a/addons/escoria-wizard/CharacterCreator.gd b/addons/escoria-wizard/CharacterCreator.gd
index 3404cd5..7281728 100644
--- a/addons/escoria-wizard/CharacterCreator.gd
+++ b/addons/escoria-wizard/CharacterCreator.gd
@@ -428,9 +428,9 @@ func draw_frame_outlines() -> void:
# When given a frame number, this calculates the pixel coordinates that frame in the spritesheet
# based on the number of horizontal/vertical frames configured for this spritesheet
-func calc_frame_coords(Frame: int) -> Vector2:
- var column = (Frame - 1) % int(get_node(ANIM_CONTROLS_NODE).get_node("h_frames_spin_box").value) * frame_size.x
- var row = int((Frame - 1) / get_node(ANIM_CONTROLS_NODE).get_node("h_frames_spin_box").value) * frame_size.y
+func calc_frame_coords(frame: int) -> Vector2:
+ var column = (frame - 1) % int(get_node(ANIM_CONTROLS_NODE).get_node("h_frames_spin_box").value) * frame_size.x
+ var row = int((frame - 1) / get_node(ANIM_CONTROLS_NODE).get_node("h_frames_spin_box").value) * frame_size.y
return Vector2(column, row)
@@ -823,7 +823,7 @@ func controls_on_v_frames_spin_box_value_changed(value: float) -> void:
# Load a spritesheet when selected in the file browser
-func controls_on_FileDialog_file_selected(path: String) -> void:
+func controls_on_file_dialog_file_selected(path: String) -> void:
get_node(NO_SPRITESHEET_NODE).visible = false
load_spritesheet(path)
@@ -1268,13 +1268,6 @@ func export_player(scene_name) -> void:
var animations_resource = ESCAnimationResource.new()
- # This is necessary to avoid a Godot bug when appending to one array
- # appends to all arrays in the same class (possibly for resources only).
- animations_resource.dir_angles = []
- animations_resource.directions = []
- animations_resource.idles = []
- animations_resource.speaks = []
-
if get_node(DIR_COUNT_NODE).get_node("four_directions").button_pressed:
num_directions = 4
start_angle_array = [315, 45, 135, 225]
@@ -1532,7 +1525,7 @@ func spritesheet_on_reset_confirmed() -> void:
character_creator_reset()
-func spritesheet_on_MainMenuConfirmation_confirmed() -> void:
+func spritesheet_on_main_menu_confirmation_confirmed() -> void:
get_node("../Menu").visible = true
get_node(".").visible = false
diff --git a/addons/escoria-wizard/CharacterCreator.tscn b/addons/escoria-wizard/CharacterCreator.tscn
index 23d41c8..e2bc78d 100644
--- a/addons/escoria-wizard/CharacterCreator.tscn
+++ b/addons/escoria-wizard/CharacterCreator.tscn
@@ -1,40 +1,40 @@
[gd_scene load_steps=40 format=3 uid="uid://cmd72pi6txw2v"]
-[ext_resource type="Texture2D" uid="uid://4e5o0jygjpmv" path="res://addons/escoria-wizard/graphics/darkgreenarrow_down.png" id="1"]
-[ext_resource type="Texture2D" uid="uid://bhtllcrcfgi2l" path="res://addons/escoria-wizard/graphics/greyarrow_r.png" id="2"]
-[ext_resource type="Texture2D" uid="uid://bf8hvhitupl4x" path="res://addons/escoria-wizard/graphics/darkgreenarrow_right.png" id="3"]
-[ext_resource type="Texture2D" uid="uid://c108mcii2pnd2" path="res://addons/escoria-wizard/graphics/greenarrow_right.png" id="4"]
-[ext_resource type="Texture2D" uid="uid://c56jk51hra2ad" path="res://addons/escoria-wizard/graphics/greenarrow_down.png" id="5"]
-[ext_resource type="Texture2D" uid="uid://dd1kynxpr5kp7" path="res://addons/escoria-wizard/graphics/greyarrow_ul.png" id="6"]
-[ext_resource type="Texture2D" uid="uid://db0x28tf68ra4" path="res://addons/escoria-wizard/graphics/greyarrow_dl.png" id="7"]
-[ext_resource type="Texture2D" uid="uid://cta81hch2hfbb" path="res://addons/escoria-wizard/graphics/darkgreyarrow_d.png" id="8"]
-[ext_resource type="Texture2D" uid="uid://bm8l75biydrpy" path="res://addons/escoria-wizard/graphics/no_animation.png" id="9"]
-[ext_resource type="Texture2D" uid="uid://cvbadwfv44b31" path="res://addons/escoria-wizard/graphics/greenarrow_downleft.png" id="10"]
-[ext_resource type="Texture2D" uid="uid://c66yr3xxyocgf" path="res://addons/escoria-wizard/graphics/darkgreenarrow_downleft.png" id="11"]
-[ext_resource type="Texture2D" uid="uid://etvk6ieq08d8" path="res://addons/escoria-wizard/graphics/darkgreenarrow_left.png" id="12"]
-[ext_resource type="Texture2D" uid="uid://r35l3buu5y1c" path="res://addons/escoria-wizard/graphics/greyarrow_l.png" id="13"]
-[ext_resource type="Texture2D" uid="uid://bkhd38cvci0o6" path="res://addons/escoria-wizard/graphics/darkgreyarrow_ur.png" id="14"]
-[ext_resource type="Texture2D" uid="uid://v431ykg6an0r" path="res://addons/escoria-wizard/graphics/darkgreyarrow_ul.png" id="15"]
-[ext_resource type="Texture2D" uid="uid://cbam52s6amdah" path="res://addons/escoria-wizard/graphics/darkgreenarrow_upright.png" id="16"]
-[ext_resource type="Texture2D" uid="uid://cadthv31wt2lr" path="res://addons/escoria-wizard/graphics/greyarrow_ur.png" id="17"]
-[ext_resource type="Texture2D" uid="uid://bu2yu64fio8re" path="res://addons/escoria-wizard/graphics/greyarrow_u.png" id="18"]
-[ext_resource type="Texture2D" uid="uid://cfewua8ben82l" path="res://addons/escoria-wizard/graphics/darkgreyarrow_r.png" id="19"]
-[ext_resource type="Texture2D" uid="uid://bng28oph8rshy" path="res://addons/escoria-wizard/graphics/darkgreyarrow_u.png" id="20"]
-[ext_resource type="Texture2D" uid="uid://dknfjhmw4ulgx" path="res://addons/escoria-wizard/graphics/darkgreyarrow_l.png" id="21"]
-[ext_resource type="Texture2D" uid="uid://cooshd11j6mme" path="res://addons/escoria-wizard/graphics/greyarrow_dr.png" id="22"]
-[ext_resource type="Texture2D" uid="uid://yhgaqrufthbd" path="res://addons/escoria-wizard/graphics/greenarrow_up.png" id="23"]
-[ext_resource type="Texture2D" uid="uid://blgnjkqah3tux" path="res://addons/escoria-wizard/graphics/greenarrow_upright.png" id="24"]
-[ext_resource type="Texture2D" uid="uid://bag4myopmjamd" path="res://addons/escoria-wizard/graphics/darkgreenarrow_downright.png" id="25"]
-[ext_resource type="Texture2D" uid="uid://dkv2avtnxqov1" path="res://addons/escoria-wizard/graphics/greenarrow_left.png" id="26"]
-[ext_resource type="Texture2D" uid="uid://b4chlwkmru1aj" path="res://addons/escoria-wizard/graphics/darkgreenarrow_upleft.png" id="27"]
-[ext_resource type="Texture2D" uid="uid://dfk2rb1g2b4gl" path="res://addons/escoria-wizard/graphics/darkgreenarrow_up.png" id="28"]
-[ext_resource type="Texture2D" uid="uid://bnxd0blo12sdc" path="res://addons/escoria-wizard/graphics/no_spritesheet.png" id="29"]
-[ext_resource type="Texture2D" uid="uid://c7hp4rcgl7v5k" path="res://addons/escoria-wizard/graphics/greenarrow_upleft.png" id="30"]
-[ext_resource type="Texture2D" uid="uid://bxfeqwi1hmjxl" path="res://addons/escoria-wizard/graphics/greenarrow_downright.png" id="31"]
-[ext_resource type="Texture2D" uid="uid://c3ohia3wlf1y7" path="res://addons/escoria-wizard/graphics/icon.png" id="32"]
-[ext_resource type="Texture2D" uid="uid://co12gknvv3u8u" path="res://addons/escoria-wizard/graphics/greyarrow_d.png" id="33"]
-[ext_resource type="Texture2D" uid="uid://cbydni2j81n0l" path="res://addons/escoria-wizard/graphics/darkgreyarrow_dr.png" id="34"]
-[ext_resource type="Texture2D" uid="uid://s7i1hfxl2dnm" path="res://addons/escoria-wizard/graphics/darkgreyarrow_dl.png" id="35"]
+[ext_resource type="Texture2D" uid="uid://bvmhh70ekt6kg" path="res://addons/escoria-wizard/graphics/darkgreenarrow_down.png" id="1"]
+[ext_resource type="Texture2D" uid="uid://trnlbw7phxmu" path="res://addons/escoria-wizard/graphics/greyarrow_r.png" id="2"]
+[ext_resource type="Texture2D" uid="uid://byldowv8jurga" path="res://addons/escoria-wizard/graphics/darkgreenarrow_right.png" id="3"]
+[ext_resource type="Texture2D" uid="uid://cssjxrowx4o4d" path="res://addons/escoria-wizard/graphics/greenarrow_right.png" id="4"]
+[ext_resource type="Texture2D" uid="uid://b57lcbtjt323p" path="res://addons/escoria-wizard/graphics/greenarrow_down.png" id="5"]
+[ext_resource type="Texture2D" uid="uid://kthnuv0ul2vl" path="res://addons/escoria-wizard/graphics/greyarrow_ul.png" id="6"]
+[ext_resource type="Texture2D" uid="uid://bqyafr0bavow4" path="res://addons/escoria-wizard/graphics/greyarrow_dl.png" id="7"]
+[ext_resource type="Texture2D" uid="uid://bghsxc7dmhcfl" path="res://addons/escoria-wizard/graphics/darkgreyarrow_d.png" id="8"]
+[ext_resource type="Texture2D" uid="uid://b7tr6x6ubkam2" path="res://addons/escoria-wizard/graphics/no_animation.png" id="9"]
+[ext_resource type="Texture2D" uid="uid://duuyiir8btchr" path="res://addons/escoria-wizard/graphics/greenarrow_downleft.png" id="10"]
+[ext_resource type="Texture2D" uid="uid://dq5q8mmk8qof2" path="res://addons/escoria-wizard/graphics/darkgreenarrow_downleft.png" id="11"]
+[ext_resource type="Texture2D" uid="uid://cvfj6jmsvx2dc" path="res://addons/escoria-wizard/graphics/darkgreenarrow_left.png" id="12"]
+[ext_resource type="Texture2D" uid="uid://cw1iq5w8e7jut" path="res://addons/escoria-wizard/graphics/greyarrow_l.png" id="13"]
+[ext_resource type="Texture2D" uid="uid://dmksuj3d4xm2r" path="res://addons/escoria-wizard/graphics/darkgreyarrow_ur.png" id="14"]
+[ext_resource type="Texture2D" uid="uid://rxcxkfkesarh" path="res://addons/escoria-wizard/graphics/darkgreyarrow_ul.png" id="15"]
+[ext_resource type="Texture2D" uid="uid://bnro12s7njjv0" path="res://addons/escoria-wizard/graphics/darkgreenarrow_upright.png" id="16"]
+[ext_resource type="Texture2D" uid="uid://bjf4n3n448rak" path="res://addons/escoria-wizard/graphics/greyarrow_ur.png" id="17"]
+[ext_resource type="Texture2D" uid="uid://c2yfth8d3cwqy" path="res://addons/escoria-wizard/graphics/greyarrow_u.png" id="18"]
+[ext_resource type="Texture2D" uid="uid://ckvsgvp6704ss" path="res://addons/escoria-wizard/graphics/darkgreyarrow_r.png" id="19"]
+[ext_resource type="Texture2D" uid="uid://cn7r214st2vlp" path="res://addons/escoria-wizard/graphics/darkgreyarrow_u.png" id="20"]
+[ext_resource type="Texture2D" uid="uid://ruc171bdoukl" path="res://addons/escoria-wizard/graphics/darkgreyarrow_l.png" id="21"]
+[ext_resource type="Texture2D" uid="uid://c1cxiw7vp3mo3" path="res://addons/escoria-wizard/graphics/greyarrow_dr.png" id="22"]
+[ext_resource type="Texture2D" uid="uid://c1ni1pr30kk0t" path="res://addons/escoria-wizard/graphics/greenarrow_up.png" id="23"]
+[ext_resource type="Texture2D" uid="uid://ddj7s5rlqkpep" path="res://addons/escoria-wizard/graphics/greenarrow_upright.png" id="24"]
+[ext_resource type="Texture2D" uid="uid://dw0f3ebq3e7om" path="res://addons/escoria-wizard/graphics/darkgreenarrow_downright.png" id="25"]
+[ext_resource type="Texture2D" uid="uid://dck3k2wchcs7o" path="res://addons/escoria-wizard/graphics/greenarrow_left.png" id="26"]
+[ext_resource type="Texture2D" uid="uid://d1s12l2v0xdh3" path="res://addons/escoria-wizard/graphics/darkgreenarrow_upleft.png" id="27"]
+[ext_resource type="Texture2D" uid="uid://7j3s0t8uxygg" path="res://addons/escoria-wizard/graphics/darkgreenarrow_up.png" id="28"]
+[ext_resource type="Texture2D" uid="uid://c3uyhfc1pejdu" path="res://addons/escoria-wizard/graphics/no_spritesheet.png" id="29"]
+[ext_resource type="Texture2D" uid="uid://dv3341fxi36co" path="res://addons/escoria-wizard/graphics/greenarrow_upleft.png" id="30"]
+[ext_resource type="Texture2D" uid="uid://bg772rhiihpya" path="res://addons/escoria-wizard/graphics/greenarrow_downright.png" id="31"]
+[ext_resource type="Texture2D" uid="uid://cmrcg0wijr8ki" path="res://addons/escoria-wizard/graphics/icon.png" id="32"]
+[ext_resource type="Texture2D" uid="uid://cx0d71n50vnrr" path="res://addons/escoria-wizard/graphics/greyarrow_d.png" id="33"]
+[ext_resource type="Texture2D" uid="uid://8i208uv8vj31" path="res://addons/escoria-wizard/graphics/darkgreyarrow_dr.png" id="34"]
+[ext_resource type="Texture2D" uid="uid://hgoiognpxbm1" path="res://addons/escoria-wizard/graphics/darkgreyarrow_dl.png" id="35"]
[ext_resource type="Script" uid="uid://ihbrgvupidxn" path="res://addons/escoria-wizard/draw_frame_rects.gd" id="36"]
[ext_resource type="Script" uid="uid://bwbxhkbjuggxq" path="res://addons/escoria-wizard/CharacterCreator.gd" id="37"]
[ext_resource type="PackedScene" uid="uid://b1mr4ghmqeyo" path="res://addons/escoria-wizard/help_window.tscn" id="38"]
@@ -985,6 +985,7 @@ layout_mode = 2
[node name="unstored_changes_window" type="Window" parent="InformationWindows"]
position = Vector2i(0, 36)
+size = Vector2i(775, 300)
visible = false
exclusive = true
@@ -1023,8 +1024,11 @@ text = "Cancel and keep editing"
[node name="unstored_changes_window_anim_change" type="Window" parent="InformationWindows"]
position = Vector2i(0, 36)
+size = Vector2i(775, 300)
visible = false
exclusive = true
+unresizable = true
+popup_window = true
[node name="Label" type="Label" parent="InformationWindows/unstored_changes_window_anim_change"]
offset_left = 86.0
@@ -1060,6 +1064,7 @@ offset_bottom = 270.0
text = "Cancel and keep editing"
[node name="generic_error_window" type="AcceptDialog" parent="InformationWindows"]
+size = Vector2i(280, 100)
dialog_text = "Please load a spritesheet to begin."
[node name="export_progress" type="Window" parent="InformationWindows"]
@@ -1118,6 +1123,7 @@ position = Vector2i(0, 36)
visible = false
[node name="ConfirmationDialog" type="ConfirmationDialog" parent="InformationWindows"]
+size = Vector2i(405, 132)
dialog_text = "WARNING!
If you continue you will lose the current character."
@@ -1167,7 +1173,7 @@ you will lose your current character.
[connection signal="pressed" from="VBoxContainer/HBoxContainer/spritesheet_controls/VBoxContainer/MarginContainer3/HBoxContainer/help_button" to="." method="spritesheet_on_help_button_pressed"]
[connection signal="pressed" from="VBoxContainer/HBoxContainer/spritesheet_controls/VBoxContainer/MarginContainer3/HBoxContainer/reset_button" to="." method="spritesheet_on_reset_button_pressed"]
[connection signal="button_up" from="VBoxContainer/HBoxContainer/spritesheet_controls/VBoxContainer/MarginContainer3/HBoxContainer/main_menu" to="." method="spritesheet_on_main_menu_button_up"]
-[connection signal="file_selected" from="ImageFileDialog" to="." method="controls_on_FileDialog_file_selected"]
+[connection signal="file_selected" from="ImageFileDialog" to="." method="controls_on_file_dialog_file_selected"]
[connection signal="dir_selected" from="CharacterPathFileDialog" to="." method="_on_CharacterPathFileDialog_dir_selected"]
[connection signal="pressed" from="InformationWindows/unstored_changes_window/commit_button" to="." method="unstored_warning_on_commit_button_pressed"]
[connection signal="pressed" from="InformationWindows/unstored_changes_window/lose_button" to="." method="unstored_warning_on_lose_button_pressed"]
@@ -1176,4 +1182,4 @@ you will lose your current character.
[connection signal="pressed" from="InformationWindows/unstored_changes_window_anim_change/lose_button" to="." method="unstored_animchange_warning_on_lose_button_pressed"]
[connection signal="pressed" from="InformationWindows/unstored_changes_window_anim_change/cancel_button" to="." method="unstored_animchange_warning_on_cancel_button_pressed"]
[connection signal="confirmed" from="InformationWindows/ConfirmationDialog" to="." method="spritesheet_on_reset_confirmed"]
-[connection signal="confirmed" from="InformationWindows/MainMenuConfirmation" to="." method="spritesheet_on_MainMenuConfirmation_confirmed"]
+[connection signal="confirmed" from="InformationWindows/MainMenuConfirmation" to="." method="spritesheet_on_main_menu_confirmation_confirmed"]
diff --git a/addons/escoria-wizard/ItemCreator.tscn b/addons/escoria-wizard/ItemCreator.tscn
index 22d735a..dabd4db 100644
--- a/addons/escoria-wizard/ItemCreator.tscn
+++ b/addons/escoria-wizard/ItemCreator.tscn
@@ -1,10 +1,10 @@
-[gd_scene load_steps=4 format=3 uid="uid://beawklamapteq"]
+[gd_scene format=3 uid="uid://beawklamapteq"]
[ext_resource type="Script" uid="uid://bxj8cgfbk5r8f" path="res://addons/escoria-wizard/item_creator.gd" id="1"]
-[ext_resource type="Texture2D" uid="uid://b7ax2phlsg1x6" path="res://addons/escoria-wizard/graphics/inventory_preview.png" id="2"]
-[ext_resource type="Texture2D" uid="uid://bf47xbq6w4hou" path="res://addons/escoria-wizard/graphics/object_preview.png" id="3"]
+[ext_resource type="Texture2D" uid="uid://mxyijpemwkkg" path="res://addons/escoria-wizard/graphics/inventory_preview.png" id="2"]
+[ext_resource type="Texture2D" uid="uid://d2chqmjr3qq3m" path="res://addons/escoria-wizard/graphics/object_preview.png" id="3"]
-[node name="ItemCreator" type="MarginContainer"]
+[node name="ItemCreator" type="MarginContainer" unique_id=691174334]
custom_minimum_size = Vector2(500, 500)
offset_left = 395.0
offset_top = 50.0
@@ -14,50 +14,50 @@ size_flags_horizontal = 4
size_flags_vertical = 4
script = ExtResource("1")
-[node name="window_background_colour" type="ColorRect" parent="."]
+[node name="window_background_colour" type="ColorRect" parent="." unique_id=1006540274]
custom_minimum_size = Vector2(500, 800)
layout_mode = 2
color = Color(0.235294, 0.341176, 0.290196, 1)
-[node name="VBoxContainer" type="VBoxContainer" parent="."]
+[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=1556992828]
layout_mode = 2
-[node name="Control" type="CenterContainer" parent="VBoxContainer"]
+[node name="Control" type="CenterContainer" parent="VBoxContainer" unique_id=272720985]
custom_minimum_size = Vector2(0, 60)
layout_mode = 2
-[node name="CenterContainer" type="CenterContainer" parent="VBoxContainer/Control"]
+[node name="CenterContainer" type="CenterContainer" parent="VBoxContainer/Control" unique_id=1294587700]
layout_mode = 2
-[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/Control/CenterContainer"]
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/Control/CenterContainer" unique_id=1459294672]
layout_mode = 2
-[node name="BackgroundObjectCheckBox" type="CheckBox" parent="VBoxContainer/Control/CenterContainer/HBoxContainer"]
+[node name="BackgroundObjectCheckBox" type="CheckBox" parent="VBoxContainer/Control/CenterContainer/HBoxContainer" unique_id=1733794135]
layout_mode = 2
text = "Create background object"
-[node name="InventoryItemCheckBox" type="CheckBox" parent="VBoxContainer/Control/CenterContainer/HBoxContainer"]
+[node name="InventoryItemCheckBox" type="CheckBox" parent="VBoxContainer/Control/CenterContainer/HBoxContainer" unique_id=1237062721]
layout_mode = 2
text = "Create inventory item"
-[node name="HSeparator" type="HSeparator" parent="VBoxContainer"]
+[node name="HSeparator" type="HSeparator" parent="VBoxContainer" unique_id=1468681635]
layout_mode = 2
-[node name="HelperHeading" type="MarginContainer" parent="VBoxContainer"]
+[node name="HelperHeading" type="MarginContainer" parent="VBoxContainer" unique_id=1282537158]
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
-[node name="CenterContainer" type="CenterContainer" parent="VBoxContainer/HelperHeading"]
+[node name="CenterContainer" type="CenterContainer" parent="VBoxContainer/HelperHeading" unique_id=407105498]
layout_mode = 2
-[node name="ObjectHeading" type="Label" parent="VBoxContainer/HelperHeading/CenterContainer"]
+[node name="ObjectHeading" type="Label" parent="VBoxContainer/HelperHeading/CenterContainer" unique_id=1913640739]
layout_mode = 2
theme_override_colors/font_color = Color(0.592157, 0.87451, 0.533333, 1)
theme_override_colors/font_shadow_color = Color(0, 0, 0, 1)
text = "Object Creator"
uppercase = true
-[node name="InventoryHeading" type="Label" parent="VBoxContainer/HelperHeading/CenterContainer"]
+[node name="InventoryHeading" type="Label" parent="VBoxContainer/HelperHeading/CenterContainer" unique_id=1615178370]
visible = false
layout_mode = 2
theme_override_colors/font_color = Color(0.592157, 0.87451, 0.533333, 1)
@@ -65,10 +65,10 @@ theme_override_colors/font_shadow_color = Color(0, 0, 0, 1)
text = "Inventory Item Creator"
uppercase = true
-[node name="Description" type="MarginContainer" parent="VBoxContainer"]
+[node name="Description" type="MarginContainer" parent="VBoxContainer" unique_id=348444932]
layout_mode = 2
-[node name="ObjectDescription" type="Label" parent="VBoxContainer/Description"]
+[node name="ObjectDescription" type="Label" parent="VBoxContainer/Description" unique_id=1367984222]
layout_mode = 2
text = "The object creator is used to create background objects
that the player can interact with, but that will not become
@@ -79,7 +79,7 @@ is currently selected in the scene tree.
"
horizontal_alignment = 1
-[node name="InventoryDescription" type="Label" parent="VBoxContainer/Description"]
+[node name="InventoryDescription" type="Label" parent="VBoxContainer/Description" unique_id=609879510]
visible = false
layout_mode = 2
text = "The inventory item creator is used to create objects
@@ -92,88 +92,91 @@ game must live in the same folder.
"
horizontal_alignment = 1
-[node name="Content" type="MarginContainer" parent="VBoxContainer"]
+[node name="Content" type="MarginContainer" parent="VBoxContainer" unique_id=990904092]
custom_minimum_size = Vector2(0, 500)
layout_mode = 2
theme_override_constants/margin_left = 20
theme_override_constants/margin_top = 0
theme_override_constants/margin_right = 20
-[node name="GridContainer" type="GridContainer" parent="VBoxContainer/Content"]
+[node name="GridContainer" type="GridContainer" parent="VBoxContainer/Content" unique_id=158727013]
custom_minimum_size = Vector2(460, 500)
layout_mode = 2
columns = 3
-[node name="ItemNameLabel" type="Label" parent="VBoxContainer/Content/GridContainer"]
+[node name="ItemNameLabel" type="Label" parent="VBoxContainer/Content/GridContainer" unique_id=364727612]
layout_mode = 2
text = "Item name:"
-[node name="ItemName" type="LineEdit" parent="VBoxContainer/Content/GridContainer"]
+[node name="ItemName" type="LineEdit" parent="VBoxContainer/Content/GridContainer" unique_id=331794259]
custom_minimum_size = Vector2(200, 0)
layout_mode = 2
text = "replace_me"
-[node name="BlankItem" type="Control" parent="VBoxContainer/Content/GridContainer"]
+[node name="BlankItem" type="Control" parent="VBoxContainer/Content/GridContainer" unique_id=63798475]
layout_mode = 2
-[node name="ItemGlobalIDLabel" type="Label" parent="VBoxContainer/Content/GridContainer"]
+[node name="ItemGlobalIDLabel" type="Label" parent="VBoxContainer/Content/GridContainer" unique_id=1865740109]
layout_mode = 2
text = "Global ID:"
-[node name="ItemGlobalID" type="LineEdit" parent="VBoxContainer/Content/GridContainer"]
+[node name="ItemGlobalID" type="LineEdit" parent="VBoxContainer/Content/GridContainer" unique_id=2143247033]
layout_mode = 2
-[node name="BlankItem2" type="Control" parent="VBoxContainer/Content/GridContainer"]
+[node name="BlankItem2" type="Control" parent="VBoxContainer/Content/GridContainer" unique_id=264709736]
layout_mode = 2
-[node name="StartsInteractiveLabel" type="Label" parent="VBoxContainer/Content/GridContainer"]
+[node name="StartsInteractiveLabel" type="Label" parent="VBoxContainer/Content/GridContainer" unique_id=479125211]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Is 'Interactive':"
-[node name="StartsInteractiveCheckBox" type="CheckBox" parent="VBoxContainer/Content/GridContainer"]
+[node name="StartsInteractiveCheckBox" type="CheckBox" parent="VBoxContainer/Content/GridContainer" unique_id=247353906]
layout_mode = 2
tooltip_text = "When the room first loads, can the player interact with this?"
button_pressed = true
-[node name="BlankItem3" type="Control" parent="VBoxContainer/Content/GridContainer"]
+[node name="BlankItem3" type="Control" parent="VBoxContainer/Content/GridContainer" unique_id=107820231]
layout_mode = 2
-[node name="DefaultActionLabel" type="Label" parent="VBoxContainer/Content/GridContainer"]
+[node name="DefaultActionLabel" type="Label" parent="VBoxContainer/Content/GridContainer" unique_id=732630412]
layout_mode = 2
text = "Default action:
"
vertical_alignment = 1
-[node name="DefaultActionOption" type="OptionButton" parent="VBoxContainer/Content/GridContainer"]
+[node name="DefaultActionOption" type="OptionButton" parent="VBoxContainer/Content/GridContainer" unique_id=606279851]
custom_minimum_size = Vector2(200, 31)
layout_mode = 2
+selected = 0
+item_count = 1
+popup/item_0/id = 0
-[node name="BlankItem4" type="Control" parent="VBoxContainer/Content/GridContainer"]
+[node name="BlankItem4" type="Control" parent="VBoxContainer/Content/GridContainer" unique_id=433328003]
layout_mode = 2
-[node name="ImagePathLabel" type="Label" parent="VBoxContainer/Content/GridContainer"]
+[node name="ImagePathLabel" type="Label" parent="VBoxContainer/Content/GridContainer" unique_id=398723320]
layout_mode = 2
text = "Item graphic:"
-[node name="ImagePath" type="LineEdit" parent="VBoxContainer/Content/GridContainer"]
+[node name="ImagePath" type="LineEdit" parent="VBoxContainer/Content/GridContainer" unique_id=938646351]
layout_mode = 2
editable = false
-[node name="ChangeImageButton" type="Button" parent="VBoxContainer/Content/GridContainer"]
+[node name="ChangeImageButton" type="Button" parent="VBoxContainer/Content/GridContainer" unique_id=1854196443]
layout_mode = 2
text = "Change Image"
-[node name="PreviewLabel" type="Label" parent="VBoxContainer/Content/GridContainer"]
+[node name="PreviewLabel" type="Label" parent="VBoxContainer/Content/GridContainer" unique_id=44519053]
layout_mode = 2
text = "Preview:"
-[node name="Preview" type="ColorRect" parent="VBoxContainer/Content/GridContainer"]
+[node name="Preview" type="ColorRect" parent="VBoxContainer/Content/GridContainer" unique_id=92417030]
custom_minimum_size = Vector2(240, 240)
layout_mode = 2
color = Color(0.121569, 0.196078, 0.0823529, 1)
-[node name="BackgroundColour" type="ColorRect" parent="VBoxContainer/Content/GridContainer/Preview"]
+[node name="BackgroundColour" type="ColorRect" parent="VBoxContainer/Content/GridContainer/Preview" unique_id=1329101639]
custom_minimum_size = Vector2(232, 232)
layout_mode = 0
offset_left = 4.0
@@ -182,7 +185,7 @@ offset_right = 236.0
offset_bottom = 236.0
color = Color(0.254902, 0.231373, 0.231373, 1)
-[node name="InventoryPreview" type="TextureRect" parent="VBoxContainer/Content/GridContainer/Preview"]
+[node name="InventoryPreview" type="TextureRect" parent="VBoxContainer/Content/GridContainer/Preview" unique_id=1373748569]
visible = false
layout_mode = 0
offset_left = 4.0
@@ -191,7 +194,7 @@ offset_right = 236.0
offset_bottom = 236.0
texture = ExtResource("2")
-[node name="ObjectPreview" type="TextureRect" parent="VBoxContainer/Content/GridContainer/Preview"]
+[node name="ObjectPreview" type="TextureRect" parent="VBoxContainer/Content/GridContainer/Preview" unique_id=134323334]
layout_mode = 0
offset_left = 4.0
offset_top = 4.0
@@ -199,7 +202,7 @@ offset_right = 236.0
offset_bottom = 236.0
texture = ExtResource("3")
-[node name="Preview" type="TextureRect" parent="VBoxContainer/Content/GridContainer/Preview"]
+[node name="Preview" type="TextureRect" parent="VBoxContainer/Content/GridContainer/Preview" unique_id=776503273]
custom_minimum_size = Vector2(232, 232)
layout_mode = 0
offset_left = 4.0
@@ -207,92 +210,96 @@ offset_top = 4.0
offset_right = 236.0
offset_bottom = 236.0
-[node name="BlankItem5" type="Control" parent="VBoxContainer/Content/GridContainer"]
+[node name="BlankItem5" type="Control" parent="VBoxContainer/Content/GridContainer" unique_id=1394861351]
layout_mode = 2
-[node name="ImageSizeLabel" type="Label" parent="VBoxContainer/Content/GridContainer"]
+[node name="ImageSizeLabel" type="Label" parent="VBoxContainer/Content/GridContainer" unique_id=212608889]
layout_mode = 2
text = "Image size:"
-[node name="ImageSize" type="Label" parent="VBoxContainer/Content/GridContainer"]
+[node name="ImageSize" type="Label" parent="VBoxContainer/Content/GridContainer" unique_id=1613836739]
layout_mode = 2
text = "(0, 0)"
-[node name="BlankItem6" type="Control" parent="VBoxContainer/Content/GridContainer"]
+[node name="BlankItem6" type="Control" parent="VBoxContainer/Content/GridContainer" unique_id=1884527510]
layout_mode = 2
-[node name="InventoryPathLabel" type="Label" parent="VBoxContainer/Content/GridContainer"]
+[node name="InventoryPathLabel" type="Label" parent="VBoxContainer/Content/GridContainer" unique_id=1516523054]
visible = false
layout_mode = 2
text = "Inventory path:"
-[node name="InventoryPath" type="Label" parent="VBoxContainer/Content/GridContainer"]
+[node name="InventoryPath" type="Label" parent="VBoxContainer/Content/GridContainer" unique_id=692354482]
visible = false
layout_mode = 2
text = "res://"
-[node name="ChangePathButton" type="Button" parent="VBoxContainer/Content/GridContainer"]
+[node name="ChangePathButton" type="Button" parent="VBoxContainer/Content/GridContainer" unique_id=216018542]
visible = false
layout_mode = 2
-text = "Change Path3D"
+text = "Change Inventory Path"
-[node name="BlankItem7" type="Control" parent="VBoxContainer/Content/GridContainer"]
+[node name="BlankItem7" type="Control" parent="VBoxContainer/Content/GridContainer" unique_id=816977697]
visible = false
layout_mode = 2
-[node name="Buttons" type="MarginContainer" parent="VBoxContainer"]
+[node name="Buttons" type="MarginContainer" parent="VBoxContainer" unique_id=317694956]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_bottom = 10
-[node name="CenterContainer" type="CenterContainer" parent="VBoxContainer/Buttons"]
+[node name="CenterContainer" type="CenterContainer" parent="VBoxContainer/Buttons" unique_id=832093664]
layout_mode = 2
-[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/Buttons/CenterContainer"]
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/Buttons/CenterContainer" unique_id=308534454]
layout_mode = 2
-[node name="CreateButton" type="Button" parent="VBoxContainer/Buttons/CenterContainer/HBoxContainer"]
+[node name="CreateButton" type="Button" parent="VBoxContainer/Buttons/CenterContainer/HBoxContainer" unique_id=103034756]
layout_mode = 2
text = "Create Object"
-[node name="Spacer" type="Control" parent="VBoxContainer/Buttons/CenterContainer/HBoxContainer"]
+[node name="Spacer" type="Control" parent="VBoxContainer/Buttons/CenterContainer/HBoxContainer" unique_id=635434229]
custom_minimum_size = Vector2(20, 0)
layout_mode = 2
-[node name="ClearButton" type="Button" parent="VBoxContainer/Buttons/CenterContainer/HBoxContainer"]
+[node name="ClearButton" type="Button" parent="VBoxContainer/Buttons/CenterContainer/HBoxContainer" unique_id=1557616416]
layout_mode = 2
text = "Clear All"
-[node name="Spacer2" type="Control" parent="VBoxContainer/Buttons/CenterContainer/HBoxContainer"]
+[node name="Spacer2" type="Control" parent="VBoxContainer/Buttons/CenterContainer/HBoxContainer" unique_id=959146374]
custom_minimum_size = Vector2(20, 0)
layout_mode = 2
-[node name="ExitButton" type="Button" parent="VBoxContainer/Buttons/CenterContainer/HBoxContainer"]
+[node name="ExitButton" type="Button" parent="VBoxContainer/Buttons/CenterContainer/HBoxContainer" unique_id=1012963769]
layout_mode = 2
text = "Main Menu"
-[node name="LoadObjectGraphic" type="CenterContainer" parent="."]
+[node name="LoadObjectGraphic" type="CenterContainer" parent="." unique_id=2039551690]
visible = false
layout_mode = 2
mouse_filter = 2
-[node name="LoadObjectFileDialog" type="FileDialog" parent="LoadObjectGraphic"]
+[node name="LoadObjectFileDialog" type="FileDialog" parent="LoadObjectGraphic" unique_id=183617470]
+oversampling_override = 1.0
+title = "Open a File"
+position = Vector2i(0, 36)
+file_mode = 0
filters = PackedStringArray("*.png", "*.bmp", "*.jpg", "*.jpeg", "*.webp", "*.tga")
-[node name="Windows" type="CenterContainer" parent="."]
+[node name="Windows" type="CenterContainer" parent="." unique_id=353631974]
visible = false
layout_mode = 2
-[node name="ConfirmationDialog" type="ConfirmationDialog" parent="Windows"]
+[node name="ConfirmationDialog" type="ConfirmationDialog" parent="Windows" unique_id=739679800]
dialog_text = "WARNING!
If you continue you will lose the current object."
-[node name="ErrorDialog" type="AcceptDialog" parent="Windows"]
+[node name="ErrorDialog" type="AcceptDialog" parent="Windows" unique_id=1922609345]
-[node name="CreateCompleteDialog" type="AcceptDialog" parent="Windows"]
+[node name="CreateCompleteDialog" type="AcceptDialog" parent="Windows" unique_id=605735600]
-[node name="FileDialog" type="FileDialog" parent="Windows"]
+[node name="FileDialog" type="FileDialog" parent="Windows" unique_id=1161118336]
mode = 2
position = Vector2i(0, 36)
diff --git a/addons/escoria-wizard/RoomCreator.gd b/addons/escoria-wizard/RoomCreator.gd
index aadda0e..8ca7c8f 100644
--- a/addons/escoria-wizard/RoomCreator.gd
+++ b/addons/escoria-wizard/RoomCreator.gd
@@ -31,16 +31,16 @@ var settings_modified: bool
func _ready() -> void:
- $"InformationWindows/PlayerSceneFileDialog".get_cancel_button().connect("pressed", Callable(self, "PlayerSceneCancelled"))
- $"InformationWindows/BackgroundImageFileDialog".get_cancel_button().connect("pressed", Callable(self, "BackgroundFileCancelled"))
+ $"InformationWindows/PlayerSceneFileDialog".get_cancel_button().connect("pressed", Callable(self, "player_scene_cancelled"))
+ $"InformationWindows/BackgroundImageFileDialog".get_cancel_button().connect("pressed", Callable(self, "background_file_cancelled"))
-func PlayerSceneCancelled() -> void:
+func player_scene_cancelled() -> void:
if get_node(PLAYER_SCENE).text == PLAYER_SELECT_TEXT:
get_node(USE_EMPTY_PLAYER_BUTTON).button_pressed = true
-func BackgroundFileCancelled() -> void:
+func background_file_cancelled() -> void:
if get_node(BACKGROUND_IMAGE).text == BACKGROUND_SELECT_TEXT:
get_node(USE_EMPTY_BACKGROUND).button_pressed = true
@@ -73,7 +73,7 @@ func _on_RoomName_text_changed(new_text: String) -> void:
settings_modified = true
-func _on_GlobalID_text_changed(new_text: String) -> void:
+func _on_GlobalID_text_changed(_new_text: String) -> void:
settings_modified = true
@@ -167,13 +167,11 @@ func set_preview_scale() -> void:
# Calculate the scale to make the preview as big as possible in the preview window depending on
# the height to width ratio of the frame
var preview_size = get_node(ROOM_BACKGROUND).get_size()
-# get_node(BACKGROUND_PREVIEW).rect_scale = Vector2.ONE
var image_size = get_node(BACKGROUND_PREVIEW).texture.get_size()
- preview_scale.x = preview_size.x / image_size.x
- preview_scale.y = preview_size.y / image_size.y
+ preview_scale.x = clampf(preview_size.x/ image_size.x, 0, 1)
+ preview_scale.y = clampf(preview_size.y/ image_size.y, 0, 1)
-# print("scale = "+str(preview_scale)+", preview size = "+str(preview_size)+", image_size = "+str(image_size))
if preview_scale.y > preview_scale.x:
get_node(BACKGROUND_PREVIEW).scale = Vector2(preview_scale.x, preview_scale.x)
else:
@@ -218,17 +216,17 @@ func _on_RoomFolderDialog_dir_selected(dir: String) -> void:
func _on_CreateButton_pressed() -> void:
- var RoomName = get_node(ROOM_NAME).text
+ var room_name = get_node(ROOM_NAME).text
- if RoomName.length() < 1:
+ if room_name.length() < 1:
$"InformationWindows/GenericErrorDialog".dialog_text = "Error!\n\nRoom name must be specified."
$"InformationWindows/GenericErrorDialog".popup_centered()
return
- var ScriptName = get_node(ESC_SCRIPT).text
+ var script_name = get_node(ESC_SCRIPT).text
if get_node(USE_EMPTY_ROOM_SCRIPT).button_pressed == false:
- if ScriptName.length() < 5 or ! ScriptName.substr(ScriptName.length() - 4) == ".esc":
+ if script_name.length() < 5 or ! script_name.substr(script_name.length() - 4) == ".esc":
$"InformationWindows/GenericErrorDialog".dialog_text = "Error!\n\n" \
+ "Room ESC script must be a filename ending in '.esc'"
$"InformationWindows/GenericErrorDialog".popup_centered()
@@ -240,82 +238,79 @@ func _on_CreateButton_pressed() -> void:
$"InformationWindows/GenericErrorDialog".popup_centered()
return
- var BaseDir = ProjectSettings.get_setting(ROOM_PATH_SETTING)
- var ImageSize = Vector2(1,1)
- var NewRoom = ESCRoom.new()
+ var base_dir = ProjectSettings.get_setting(ROOM_PATH_SETTING)
+ var new_room = ESCRoom.new()
- NewRoom.name = RoomName
- NewRoom.global_id = get_node(GLOBAL_ID).text
+ new_room.name = room_name
+ new_room.global_id = get_node(GLOBAL_ID).text
if ! get_node(ESC_SCRIPT).text == SCRIPT_SELECT_TEXT and ! get_node(ESC_SCRIPT).text == SCRIPT_BLANK_TEXT:
- NewRoom.esc_script = "%s/%s/scripts/%s" % [BaseDir, RoomName, get_node(ESC_SCRIPT).text]
+ new_room.esc_script = "%s/%s/scripts/%s" % [base_dir, room_name, get_node(ESC_SCRIPT).text]
if ! get_node(PLAYER_SCENE).text == PLAYER_SELECT_TEXT and ! get_node(PLAYER_SCENE).text == PLAYER_BLANK_TEXT:
var player_scene = load(get_node(PLAYER_SCENE).text)
- NewRoom.player_scene = player_scene
+ new_room.player_scene = player_scene
- var Background = ESCBackground.new()
- Background.name = "Background"
+ var background = ESCBackground.new()
+ background.name = "Background"
- var BackgroundSize = Vector2.ONE
+ var background_size = Vector2.ONE
if ! get_node(BACKGROUND_IMAGE).text == BACKGROUND_SELECT_TEXT and ! get_node(BACKGROUND_IMAGE).text == BACKGROUND_BLANK_TEXT:
- Background.texture = get_node(BACKGROUND_PREVIEW).texture
- BackgroundSize = Background.texture.get_size()
+ background.texture = get_node(BACKGROUND_PREVIEW).texture
+ background_size = background.texture.get_size()
else:
# Set TextureRect to have the same size as the Viewport so that the room
# works even if no texture is set in the TextureRect
- BackgroundSize = Vector2(ProjectSettings.get_setting("display/window/size/viewport_width"), \
+ background_size = Vector2(ProjectSettings.get_setting("display/window/size/viewport_width"), \
ProjectSettings.get_setting("display/window/size/viewport_height"))
- Background.size = BackgroundSize
+ background.size = background_size
- NewRoom.add_child(Background)
+ new_room.add_child(background)
+ background.set_owner(new_room)
- var NewTerrain = ESCTerrain.new()
- NewTerrain.name = "WalkableArea"
- var NewNavigationPolygonInstance = NavigationRegion2D.new()
+ var new_terrain = ESCTerrain.new()
+ new_terrain.name = "WalkableArea"
+ var new_navigation_polygon_instance = NavigationRegion2D.new()
+ var new_navigation_polygon = NavigationPolygon.new()
+ new_navigation_polygon_instance.navigation_polygon = new_navigation_polygon
+ new_terrain.add_child(new_navigation_polygon_instance)
+ new_navigation_polygon_instance.set_owner(new_terrain)
+ new_room.add_child(new_terrain)
- var NewNavigationPolygon = NavigationPolygon.new()
- NewNavigationPolygonInstance.navigation_polygon = NewNavigationPolygon
- NewRoom.add_child(NewTerrain)
+ new_terrain.set_owner(new_room)
- NewTerrain.add_child(NewNavigationPolygonInstance)
+ var objects = Node2D.new()
+ objects.name = "RoomObjects"
+ new_room.add_child(objects)
+ objects.set_owner(new_room)
- var Objects = Node2D.new()
- Objects.name = "RoomObjects"
- NewRoom.add_child(Objects)
+ var start_pos = ESCLocation.new()
+ start_pos.name = "StartPos"
+ start_pos.is_start_location = true
+ start_pos.global_id = "%s_start_pos" % room_name
+ start_pos.position = Vector2(int(background_size.x / 2), int(background_size.y / 2))
+ new_room.add_child(start_pos)
+ start_pos.set_owner(new_room)
- var StartPos = ESCLocation.new()
- StartPos.name = "StartPos"
- StartPos.is_start_location = true
- StartPos.global_id = "%s_start_pos" % RoomName
- StartPos.position = Vector2(int(BackgroundSize.x / 2), int(BackgroundSize.y / 2))
- NewRoom.add_child(StartPos)
-
- get_tree().edited_scene_root.add_child(NewRoom)
- NewRoom.set_owner(get_tree().edited_scene_root)
- NewNavigationPolygonInstance.set_owner(NewRoom)
- NewTerrain.set_owner(NewRoom)
- Background.set_owner(NewRoom)
- Objects.set_owner(NewRoom)
- StartPos.set_owner(NewRoom)
-
- DirAccess.make_dir_recursive_absolute("%s/%s/scripts" % [BaseDir, RoomName])
- DirAccess.make_dir_recursive_absolute("%s/%s/objects" % [BaseDir, RoomName])
+ DirAccess.make_dir_recursive_absolute("%s/%s/scripts" % [base_dir, room_name])
+ DirAccess.make_dir_recursive_absolute("%s/%s/objects" % [base_dir, room_name])
DirAccess.copy_absolute("res://addons/escoria-wizard/room_script_template.esc", "%s/%s/scripts/%s" % \
- [BaseDir, RoomName, get_node(ESC_SCRIPT).text])
+ [base_dir, room_name, get_node(ESC_SCRIPT).text])
# Export scene
var packed_scene = PackedScene.new()
- packed_scene.pack(get_tree().edited_scene_root.get_node(NewRoom.name))
+ packed_scene.pack(new_room)
# Flag suggestions from https://godotengine.org/qa/50437/how-to-turn-a-node-into-a-packedscene-via-gdscript
- ResourceSaver.save(packed_scene, "%s/%s/%s.tscn" % [BaseDir, RoomName, RoomName], \
+ var error = ResourceSaver.save(packed_scene, "%s/%s/%s.tscn" % [base_dir, room_name, room_name], \
ResourceSaver.FLAG_CHANGE_PATH|ResourceSaver.FLAG_REPLACE_SUBRESOURCE_PATHS)
+ if error != OK:
+ push_error("An error occurred while saving the scene to disk.")
+
+ new_room.queue_free()
- NewRoom.queue_free()
- get_tree().edited_scene_root.get_node(NewRoom.name).queue_free()
# Scan the filesystem so that the new folders show up in the file browser.
# Without this you might not see the objects/scripts folders in the filetree.
var ep = EditorPlugin.new()
diff --git a/addons/escoria-wizard/RoomCreator.tscn b/addons/escoria-wizard/RoomCreator.tscn
index b7a4210..3828236 100644
--- a/addons/escoria-wizard/RoomCreator.tscn
+++ b/addons/escoria-wizard/RoomCreator.tscn
@@ -1,117 +1,117 @@
-[gd_scene load_steps=3 format=3 uid="uid://b72fr6t0ed4s0"]
+[gd_scene format=3 uid="uid://b72fr6t0ed4s0"]
[ext_resource type="Script" uid="uid://bqxah4gdmfyu6" path="res://addons/escoria-wizard/RoomCreator.gd" id="1"]
-[ext_resource type="Texture2D" uid="uid://clm4n1qg8fidx" path="res://addons/escoria-wizard/graphics/background_preview.png" id="2"]
+[ext_resource type="Texture2D" uid="uid://b3wba8cg2si2b" path="res://addons/escoria-wizard/graphics/background_preview.png" id="2"]
-[node name="CenterContainer" type="CenterContainer"]
+[node name="CenterContainer" type="CenterContainer" unique_id=443476480]
clip_contents = true
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1")
-[node name="MarginContainer" type="MarginContainer" parent="."]
+[node name="MarginContainer" type="MarginContainer" parent="." unique_id=1524949415]
layout_mode = 2
-[node name="ColorRect" type="ColorRect" parent="MarginContainer"]
+[node name="ColorRect" type="ColorRect" parent="MarginContainer" unique_id=2096681083]
layout_mode = 2
color = Color(0.235294, 0.341176, 0.290196, 1)
-[node name="MarginContainer" type="MarginContainer" parent="MarginContainer"]
+[node name="MarginContainer" type="MarginContainer" parent="MarginContainer" unique_id=1109497573]
layout_mode = 2
theme_override_constants/margin_left = 5
theme_override_constants/margin_top = 5
theme_override_constants/margin_right = 5
theme_override_constants/margin_bottom = 5
-[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/MarginContainer"]
+[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/MarginContainer" unique_id=1940588931]
layout_mode = 2
-[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/MarginContainer/VBoxContainer"]
+[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/MarginContainer/VBoxContainer" unique_id=85037514]
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
-[node name="Label" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/CenterContainer"]
+[node name="Label" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/CenterContainer" unique_id=1156009861]
layout_mode = 2
theme_override_colors/font_color = Color(0.921569, 1, 0, 1)
theme_override_colors/font_shadow_color = Color(0, 0, 0, 1)
text = "Room Creator"
uppercase = true
-[node name="MarginContainer" type="MarginContainer" parent="MarginContainer/MarginContainer/VBoxContainer"]
+[node name="MarginContainer" type="MarginContainer" parent="MarginContainer/MarginContainer/VBoxContainer" unique_id=1511603618]
custom_minimum_size = Vector2(460, 200)
layout_mode = 2
-[node name="GridContainer" type="GridContainer" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer"]
+[node name="GridContainer" type="GridContainer" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer" unique_id=2053595885]
custom_minimum_size = Vector2(300, 200)
layout_mode = 2
theme_override_constants/h_separation = 10
columns = 4
-[node name="RoomNameLabel" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="RoomNameLabel" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=190169202]
layout_mode = 2
text = "Room name:"
-[node name="RoomName" type="LineEdit" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="RoomName" type="LineEdit" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=606912093]
custom_minimum_size = Vector2(400, 0)
layout_mode = 2
caret_blink = true
caret_blink_interval = 0.5
-[node name="Spacer3" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="Spacer3" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=2009043994]
custom_minimum_size = Vector2(40, 0)
layout_mode = 2
-[node name="Spacer6" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="Spacer6" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=2034489688]
custom_minimum_size = Vector2(200, 0)
layout_mode = 2
-[node name="GlobalIDLabel" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="GlobalIDLabel" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=1440682867]
layout_mode = 2
text = "Global ID:"
-[node name="GlobalID" type="LineEdit" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="GlobalID" type="LineEdit" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=1379464115]
layout_mode = 2
caret_blink = true
caret_blink_interval = 0.5
-[node name="Spacer4" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="Spacer4" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=1668260232]
custom_minimum_size = Vector2(40, 0)
layout_mode = 2
-[node name="Spacer7" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="Spacer7" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=166896264]
custom_minimum_size = Vector2(40, 0)
layout_mode = 2
-[node name="PlayerSceneLabel" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="PlayerSceneLabel" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=992585730]
layout_mode = 2
text = "Player scene:"
-[node name="PlayerScene" type="LineEdit" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="PlayerScene" type="LineEdit" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=1764680015]
layout_mode = 2
text = "Scene will be left blank."
editable = false
caret_blink = true
caret_blink_interval = 0.5
-[node name="UseEmptyPlayerButton" type="CheckButton" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="UseEmptyPlayerButton" type="CheckButton" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=1137816360]
layout_mode = 2
text = "Use empty player scene"
-[node name="SelectPlayerScene" type="Button" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="SelectPlayerScene" type="Button" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=1850650331]
visible = false
layout_mode = 2
text = "Select Player Scene"
-[node name="SelectPlayerSceneSpacer" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="SelectPlayerSceneSpacer" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=1158438388]
custom_minimum_size = Vector2(40, 0)
layout_mode = 2
-[node name="ESCScriptLabel" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="ESCScriptLabel" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=1226139985]
layout_mode = 2
text = "Room ESC script:"
-[node name="ESCScript" type="LineEdit" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="ESCScript" type="LineEdit" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=94388860]
layout_mode = 2
tooltip_text = "In the room's scripts folder
an empty file will be created
@@ -121,43 +121,43 @@ editable = false
caret_blink = true
caret_blink_interval = 0.5
-[node name="UseEmptyRoomScript" type="CheckButton" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="UseEmptyRoomScript" type="CheckButton" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=2089004824]
layout_mode = 2
text = "No script"
-[node name="UseEmptyRoomSpacer" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="UseEmptyRoomSpacer" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=170754280]
custom_minimum_size = Vector2(40, 0)
layout_mode = 2
-[node name="BackgroundImageLabel" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="BackgroundImageLabel" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=2118551006]
layout_mode = 2
text = "Background image:"
-[node name="BackgroundImage" type="LineEdit" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="BackgroundImage" type="LineEdit" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=1361723740]
layout_mode = 2
text = "Image will be left blank."
editable = false
caret_blink = true
caret_blink_interval = 0.5
-[node name="UseEmptyBackground" type="CheckButton" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="UseEmptyBackground" type="CheckButton" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=434839193]
layout_mode = 2
text = "Use empty background"
-[node name="SelectBackground" type="Button" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="SelectBackground" type="Button" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=123588015]
visible = false
layout_mode = 2
text = "Select Background"
-[node name="SelectBackgroundSpacer" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="SelectBackgroundSpacer" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=96857991]
custom_minimum_size = Vector2(40, 0)
layout_mode = 2
-[node name="RoomFolderLabel" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="RoomFolderLabel" type="Label" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=1807096131]
layout_mode = 2
text = "Room folder parent:"
-[node name="RoomFolder" type="LineEdit" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="RoomFolder" type="LineEdit" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=1262301674]
layout_mode = 2
tooltip_text = "A folder (<Global ID>) for your room will be created inside this parent folder."
text = "res://game/rooms"
@@ -165,99 +165,101 @@ editable = false
caret_blink = true
caret_blink_interval = 0.5
-[node name="ChangeRoomFolderButton" type="Button" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="ChangeRoomFolderButton" type="Button" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=1061137733]
layout_mode = 2
text = "Change Room Folder"
-[node name="Spacer8" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer"]
+[node name="Spacer8" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer" unique_id=371909358]
custom_minimum_size = Vector2(40, 0)
layout_mode = 2
-[node name="PreviewSection" type="MarginContainer" parent="MarginContainer/MarginContainer/VBoxContainer"]
+[node name="PreviewSection" type="MarginContainer" parent="MarginContainer/MarginContainer/VBoxContainer" unique_id=544658158]
custom_minimum_size = Vector2(1000, 400)
layout_mode = 2
-[node name="PreviewFrame" type="ColorRect" parent="MarginContainer/MarginContainer/VBoxContainer/PreviewSection"]
+[node name="PreviewFrame" type="ColorRect" parent="MarginContainer/MarginContainer/VBoxContainer/PreviewSection" unique_id=427730384]
custom_minimum_size = Vector2(1000, 400)
layout_mode = 2
color = Color(0, 0, 0, 1)
-[node name="CenterContainer" type="MarginContainer" parent="MarginContainer/MarginContainer/VBoxContainer/PreviewSection"]
+[node name="CenterContainer" type="MarginContainer" parent="MarginContainer/MarginContainer/VBoxContainer/PreviewSection" unique_id=360840181]
layout_mode = 2
-[node name="RoomBackground" type="TextureRect" parent="MarginContainer/MarginContainer/VBoxContainer/PreviewSection/CenterContainer"]
+[node name="RoomBackground" type="TextureRect" parent="MarginContainer/MarginContainer/VBoxContainer/PreviewSection/CenterContainer" unique_id=1552477363]
custom_minimum_size = Vector2(990, 390)
layout_mode = 2
texture = ExtResource("2")
expand_mode = 1
stretch_mode = 7
-[node name="Control" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/PreviewSection"]
+[node name="Control" type="Control" parent="MarginContainer/MarginContainer/VBoxContainer/PreviewSection" unique_id=1150908173]
layout_mode = 2
-[node name="BackgroundPreview" type="TextureRect" parent="MarginContainer/MarginContainer/VBoxContainer/PreviewSection/Control"]
+[node name="BackgroundPreview" type="TextureRect" parent="MarginContainer/MarginContainer/VBoxContainer/PreviewSection/Control" unique_id=1507500464]
layout_mode = 0
offset_right = 1000.0
offset_bottom = 400.0
-[node name="MarginContainer2" type="MarginContainer" parent="MarginContainer/MarginContainer/VBoxContainer"]
+[node name="MarginContainer2" type="MarginContainer" parent="MarginContainer/MarginContainer/VBoxContainer" unique_id=91854857]
custom_minimum_size = Vector2(0, 80)
layout_mode = 2
-[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer2"]
+[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer2" unique_id=117241011]
custom_minimum_size = Vector2(800, 80)
layout_mode = 2
-[node name="GridContainer" type="GridContainer" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer2/CenterContainer"]
+[node name="GridContainer" type="GridContainer" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer2/CenterContainer" unique_id=93516859]
custom_minimum_size = Vector2(300, 40)
layout_mode = 2
theme_override_constants/h_separation = 40
columns = 3
-[node name="CreateButton" type="Button" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer2/CenterContainer/GridContainer"]
+[node name="CreateButton" type="Button" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer2/CenterContainer/GridContainer" unique_id=1783379866]
layout_mode = 2
text = "Create Room"
-[node name="ClearButton" type="Button" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer2/CenterContainer/GridContainer"]
+[node name="ClearButton" type="Button" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer2/CenterContainer/GridContainer" unique_id=495821110]
layout_mode = 2
text = "Clear"
-[node name="MainMenuButton" type="Button" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer2/CenterContainer/GridContainer"]
+[node name="MainMenuButton" type="Button" parent="MarginContainer/MarginContainer/VBoxContainer/MarginContainer2/CenterContainer/GridContainer" unique_id=560281339]
layout_mode = 2
text = "Main Menu"
-[node name="InformationWindows" type="Control" parent="."]
+[node name="InformationWindows" type="Control" parent="." unique_id=1537642924]
layout_mode = 2
-[node name="PlayerSceneFileDialog" type="FileDialog" parent="InformationWindows"]
+[node name="PlayerSceneFileDialog" type="FileDialog" parent="InformationWindows" unique_id=1145823587]
filters = PackedStringArray("*.tscn, *.scn ; Scene Files")
-[node name="ESCScriptFileDialog" type="FileDialog" parent="InformationWindows"]
+[node name="ESCScriptFileDialog" type="FileDialog" parent="InformationWindows" unique_id=2037463107]
filters = PackedStringArray("*.esc; Escoria script files")
-[node name="BackgroundImageFileDialog" type="FileDialog" parent="InformationWindows"]
+[node name="BackgroundImageFileDialog" type="FileDialog" parent="InformationWindows" unique_id=527515950]
+title = "Open a File"
+file_mode = 0
filters = PackedStringArray("*.png, *.jpg, *.jpeg ; Supported Images")
-[node name="ClearConfirmationDialog" type="ConfirmationDialog" parent="InformationWindows"]
+[node name="ClearConfirmationDialog" type="ConfirmationDialog" parent="InformationWindows" unique_id=909240617]
dialog_text = "WARNING!
If you continue you will lose the current room setup."
-[node name="MainMenuConfirmationDialog" type="ConfirmationDialog" parent="InformationWindows"]
+[node name="MainMenuConfirmationDialog" type="ConfirmationDialog" parent="InformationWindows" unique_id=1514890737]
dialog_text = "WARNING!
If you continue you will lose the current room setup."
-[node name="GenericErrorDialog" type="AcceptDialog" parent="InformationWindows"]
+[node name="GenericErrorDialog" type="AcceptDialog" parent="InformationWindows" unique_id=1255486392]
dialog_text = "ERROR!
Please supply a name for the room."
-[node name="CreateCompleteDialog" type="AcceptDialog" parent="InformationWindows"]
+[node name="CreateCompleteDialog" type="AcceptDialog" parent="InformationWindows" unique_id=121100860]
dialog_text = "The room has been created."
-[node name="RoomFolderDialog" type="FileDialog" parent="InformationWindows"]
-mode = 2
+[node name="RoomFolderDialog" type="FileDialog" parent="InformationWindows" unique_id=1336376019]
+file_mode = 2
[connection signal="text_changed" from="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer/RoomName" to="." method="_on_RoomName_text_changed"]
[connection signal="text_changed" from="MarginContainer/MarginContainer/VBoxContainer/MarginContainer/GridContainer/GlobalID" to="." method="_on_GlobalID_text_changed"]
diff --git a/addons/escoria-wizard/item_creator.gd b/addons/escoria-wizard/item_creator.gd
index 6a279b8..93b08ce 100644
--- a/addons/escoria-wizard/item_creator.gd
+++ b/addons/escoria-wizard/item_creator.gd
@@ -1,3 +1,6 @@
+# gdlint:disable=function-name
+# gdlint:disable=no-else-return
+# TODO: Fix this file's legacy lint violations and remove the gdlint disables above.
@tool
extends MarginContainer
@@ -36,27 +39,29 @@ var preview_size:Vector2
# Called when the node enters the scene tree for the first time.
+
func _ready() -> void:
# Capture the size of the window before we update its contents so we have
# the absolute size before it gets scaled contents applied to it
- preview_size = get_node(PREVIEW_NODE).size
+ preview_size = get_node(PREVIEW_NODE).get_size()
inventory_mode = not get_node(BACKGROUND_OBJ_NODE).pressed
+
+ var action_option: OptionButton = get_node(ACTION_NODE)
+ action_option.clear()
+ for option_list in ["", "look", "pick up", "open", "close", "use", "push", "pull", "talk"]:
+ action_option.add_item(option_list)
+
item_creator_reset()
+
func item_creator_reset() -> void:
get_node(ITEM_NAME_NODE).text = "replace_me"
get_node(GLOBAL_ID_NODE).text = ""
get_node(IMAGE_PATH_NODE).text = ""
get_node(INTERACTIVE_NODE).button_pressed = true
-
- if get_node(ACTION_NODE).get_item_count() > 0:
- get_node(ACTION_NODE).clear()
-
- for option_list in ["look", "pick up", "open", "close", "use", "push", "pull", "talk"]:
- get_node(ACTION_NODE).add_item(option_list)
-
get_node(ACTION_NODE).selected = 0
+
image_size = Vector2.ZERO
image_has_been_loaded = false
main_menu_requested = false
@@ -91,9 +96,10 @@ func resize_image() -> void:
# Calculate the scale to make the preview as big as possible in the preview window depending on
# the height to width ratio of the frame
image_size = image_stream_texture.get_size()
+
var preview_scale = Vector2.ONE
- preview_scale.x = preview_size.x / image_size.x
- preview_scale.y = preview_size.y / image_size.y
+ preview_scale.x = clampf(preview_size.x / image_size.x, 0, 1)
+ preview_scale.y = clampf(preview_size.y / image_size.y, 0, 1)
if preview_scale.y > preview_scale.x:
get_node(PREVIEW_NODE).scale = Vector2(preview_scale.x, preview_scale.x)
@@ -110,12 +116,17 @@ func load_button_pressed() -> void:
func LoadObjectFileDialog_file_selected(path: String) -> void:
- image_stream_texture = load(path)
-
- get_node(PREVIEW_NODE).texture = image_stream_texture
+ var loaded_texture := load(path) as CompressedTexture2D
+ if loaded_texture == null:
+ get_node(ERROR_WINDOW_NODE).dialog_text = "Failed to load image: %s" % path
+ get_node(ERROR_WINDOW_NODE).popup_centered()
+ return
+ image_stream_texture = loaded_texture
resize_image()
+ get_node(PREVIEW_NODE).texture = image_stream_texture
+
get_node(IMAGE_SIZE_NODE).text = "(%s, %s)" % [image_size.x, image_size.y]
get_node(IMAGE_PATH_NODE).text = path
@@ -155,17 +166,17 @@ func _on_CreateButton_pressed() -> void:
var item = ESCItem.new()
item.name = get_node(ITEM_NAME_NODE).text
item.global_id = get_node(GLOBAL_ID_NODE).text
- item.is_interactive = get_node(INTERACTIVE_NODE).pressed
+ item.is_interactive = get_node(INTERACTIVE_NODE).button_pressed
item.tooltip_name = get_node(ITEM_NAME_NODE).text
var selected_index = get_node(ACTION_NODE).selected
item.default_action = get_node(ACTION_NODE).get_item_text(selected_index)
- # Make the item by default it's usable straight out of the inventory
+ # Make the item use a second target object by default when used from inventory.
if inventory_mode == true:
- var new_pool_array: PackedStringArray = item.combine_when_selected_action_is_in
+ var new_pool_array: PackedStringArray = item.actions_requiring_target_object
new_pool_array.append("use")
- item.combine_when_selected_action_is_in = new_pool_array
+ item.actions_requiring_target_object = new_pool_array
# Add Dialog Position to the background item
var interact_position = ESCLocation.new()
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/plugin.cfg b/addons/gdUnit4/plugin.cfg
new file mode 100644
index 0000000..8293c3f
--- /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.1.1"
+script="plugin.gd"
diff --git a/addons/gdUnit4/plugin.gd b/addons/gdUnit4/plugin.gd
new file mode 100644
index 0000000..1e220c0
--- /dev/null
+++ b/addons/gdUnit4/plugin.gd
@@ -0,0 +1,101 @@
+@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: int = ProjectSettings.get_setting("debug/gdscript/warnings/inferred_declaration")
+
+ var is_gdunit_excluded_warnings: bool = false
+ if Engine.get_version_info().hex >= 0x40600:
+ var dirctrory_rules: Dictionary = ProjectSettings.get_setting("debug/gdscript/warnings/directory_rules")
+ if dirctrory_rules.has("res://addons/gdUnit4") and dirctrory_rules["res://addons/gdUnit4"] == 0:
+ is_gdunit_excluded_warnings = true
+ else:
+ is_gdunit_excluded_warnings = ProjectSettings.get_setting("debug/gdscript/warnings/exclude_addons")
+ if !is_gdunit_excluded_warnings and inferred_declaration != 0:
+ printerr("GdUnit4: 'inferred_declaration' is set to Warning/Error!")
+ if Engine.get_version_info().hex >= 0x40600:
+ printerr("GdUnit4 is not 'inferred_declaration' save, you have to excluded the addon (debug/gdscript/warnings/directory_rules)")
+ else:
+ printerr("GdUnit4 is not 'inferred_declaration' save, you have to excluded addons (debug/gdscript/warnings/exclude_addons)")
+ 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()
+ # 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..9761828
--- /dev/null
+++ b/addons/gdUnit4/plugin.gd.uid
@@ -0,0 +1 @@
+uid://2b3m6m3mq6wg
diff --git a/addons/gdUnit4/runtest.cmd b/addons/gdUnit4/runtest.cmd
new file mode 100644
index 0000000..ad5da4d
--- /dev/null
+++ b/addons/gdUnit4/runtest.cmd
@@ -0,0 +1,62 @@
+@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
+"!godot_binary!" --path . -s -d 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..f0269ef
--- /dev/null
+++ b/addons/gdUnit4/runtest.sh
@@ -0,0 +1,62 @@
+#!/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
+"$godot_binary" --path . -s -d 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..6da8cd4
--- /dev/null
+++ b/addons/gdUnit4/src/Comparator.gd.uid
@@ -0,0 +1 @@
+uid://dmyhfgv1bt2lh
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..1168c34
--- /dev/null
+++ b/addons/gdUnit4/src/Fuzzers.gd.uid
@@ -0,0 +1 @@
+uid://dbbi3yw3gch5l
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..0da97e4
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitArrayAssert.gd.uid
@@ -0,0 +1 @@
+uid://cunpqk22lw7v7
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..cb02773
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitAssert.gd.uid
@@ -0,0 +1 @@
+uid://drtok3us6xriq
diff --git a/addons/gdUnit4/src/GdUnitAwaiter.gd b/addons/gdUnit4/src/GdUnitAwaiter.gd
new file mode 100644
index 0000000..51385e8
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitAwaiter.gd
@@ -0,0 +1,72 @@
+class_name GdUnitAwaiter
+extends RefCounted
+
+
+# Waits for a specified signal in an interval of 50ms sent from the <source>, 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 line_number := GdUnitAssertions.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 <source> 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 := GdUnitAssertions.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..d5f86cb
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitAwaiter.gd.uid
@@ -0,0 +1 @@
+uid://bxu4aimnsxf6a
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..1b38958
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitBoolAssert.gd.uid
@@ -0,0 +1 @@
+uid://chfmmumtcb0rt
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..887fffc
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitConstants.gd.uid
@@ -0,0 +1 @@
+uid://bt8507s1msjml
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..8c20dba
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitDictionaryAssert.gd.uid
@@ -0,0 +1 @@
+uid://dqrcom5g63buq
diff --git a/addons/gdUnit4/src/GdUnitFailureAssert.gd b/addons/gdUnit4/src/GdUnitFailureAssert.gd
new file mode 100644
index 0000000..6fec191
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFailureAssert.gd
@@ -0,0 +1,52 @@
+## 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 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..339bdc4
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFailureAssert.gd.uid
@@ -0,0 +1 @@
+uid://doah5qceavyym
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..2952ee1
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFileAssert.gd.uid
@@ -0,0 +1 @@
+uid://b60x3ixhqf6o7
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..f8a9d62
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFloatAssert.gd.uid
@@ -0,0 +1 @@
+uid://c8o2ucj3eairh
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..f181d94
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFuncAssert.gd.uid
@@ -0,0 +1 @@
+uid://bu7c3ksp8ncrm
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(<callable>).is_success()
+## [/codeblock]
+@abstract func is_success() -> GdUnitGodotErrorAssert
+
+
+## Verifies if the executed code runs into a runtime error
+## Usage:
+## [codeblock]
+## await assert_error(<callable>).is_runtime_error(<expected error message>)
+## [/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(<callable>).is_push_warning(<expected push warning message>)
+## [/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(<callable>).is_push_error(<expected push error message>)
+## [/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..8b1c6c7
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd.uid
@@ -0,0 +1 @@
+uid://c72hekdqe2np7
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..22b6ec0
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitIntAssert.gd.uid
@@ -0,0 +1 @@
+uid://ctx5mjsksw4t8
diff --git a/addons/gdUnit4/src/GdUnitObjectAssert.gd b/addons/gdUnit4/src/GdUnitObjectAssert.gd
new file mode 100644
index 0000000..9d7e76e
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitObjectAssert.gd
@@ -0,0 +1,51 @@
+## 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 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..0ad3f3b
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitObjectAssert.gd.uid
@@ -0,0 +1 @@
+uid://chg5kc3ebu05
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..265e4a5
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitResultAssert.gd.uid
@@ -0,0 +1 @@
+uid://3idlr54yows6
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 <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 <value> of the property with the name <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 <name> 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..a150947
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitSceneRunner.gd.uid
@@ -0,0 +1 @@
+uid://vtg8n735n6xr
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..df6df39
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitSignalAssert.gd.uid
@@ -0,0 +1 @@
+uid://bmdasvsfnvbl8
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..0487c05
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitStringAssert.gd.uid
@@ -0,0 +1 @@
+uid://bp8o13nmu5j3i
diff --git a/addons/gdUnit4/src/GdUnitTestSuite.gd b/addons/gdUnit4/src/GdUnitTestSuite.gd
new file mode 100644
index 0000000..5cf269f
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitTestSuite.gd
@@ -0,0 +1,746 @@
+## 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
+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()
+
+ assert(execution_context != null, "INTERNAL ERROR: The current execution_context is null! Please report this as bug.")
+ return execution_context.register_auto_free(obj)
+
+
+@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* + <relative_path>[br]
+## with given name <file_name> and given file <mode> (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 <resource_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 <resource_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 <resource_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 <source> 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.
+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()
+
+
+## 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).report_error(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..848c896
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitTestSuite.gd.uid
@@ -0,0 +1 @@
+uid://dssjps8fb6m2w
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..a5307d6
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitTuple.gd.uid
@@ -0,0 +1 @@
+uid://b1rha2l11wgl6
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..0e54c3f
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitValueExtractor.gd.uid
@@ -0,0 +1 @@
+uid://de3g3qotv2c5v
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..aaf7d12
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitVectorAssert.gd.uid
@@ -0,0 +1 @@
+uid://2p2gh0psu842
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..20374e1
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/CallBackValueProvider.gd.uid
@@ -0,0 +1 @@
+uid://da0u7lr425ojb
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..0759472
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/DefaultValueProvider.gd.uid
@@ -0,0 +1 @@
+uid://d0oppd5oc256x
diff --git a/addons/gdUnit4/src/asserts/GdAssertMessages.gd b/addons/gdUnit4/src/asserts/GdAssertMessages.gd
new file mode 100644
index 0000000..5a2fa1c
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdAssertMessages.gd
@@ -0,0 +1,752 @@
+class_name GdAssertMessages
+extends Resource
+
+const WARN_COLOR = "#EFF883"
+const ERROR_COLOR = "#CD5C5C"
+const VALUE_COLOR = "#1E90FF"
+const SUB_COLOR := Color(1, 0, 0, .15)
+const ADD_COLOR := Color(0, 1, 0, .15)
+
+
+# Dictionary of control characters and their readable representations
+const CONTROL_CHARS = {
+ "\n": "<LF>", # Line Feed
+ "\r": "<CR>", # Carriage Return
+ "\t": "<TAB>", # Tab
+ "\b": "<BS>", # Backspace
+ "\f": "<FF>", # Form Feed
+ "\v": "<VT>", # Vertical Tab
+ "\a": "<BEL>", # Bell
+ "": "<ESC>" # Escape
+}
+
+
+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 "<empty>"
+ 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]<null>[/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) -> String:
+ return """
+ %s: Found %s possible orphan nodes.
+ Add %s to the end of the test to collect details.""".dedent().trim_prefix("\n") % [
+ _warning("WARNING:"),
+ _nerror(orphans_count),
+ _colored_value("collect_orphan_node_details()")
+ ]
+
+static func orphan_detected_on_suite_setup(orphans: Array[GdUnitOrphanNodeInfo]) -> String:
+ return """
+ %s Detected %s orphan nodes!
+ [b]Verify your test suite setup.[/b]
+ %s""".dedent().trim_prefix("\n") % [
+ _warning("WARNING:"),
+ _nerror(orphans.size()),
+ _build_orphan_node_stacktrace(orphans)]
+
+
+static func orphan_detected_on_test_setup(orphans: Array[GdUnitOrphanNodeInfo]) -> String:
+ return """
+ %s Detected %s orphan nodes on test setup!
+ [b]Check before_test() and after_test()![/b]
+ %s""".dedent().trim_prefix("\n") % [
+ _warning("WARNING:"),
+ _nerror(orphans.size()),
+ _build_orphan_node_stacktrace(orphans)
+ ]
+
+
+static func orphan_detected_on_test(orphans: Array[GdUnitOrphanNodeInfo]) -> String:
+ return """
+ %s Detected %s orphan nodes!
+ %s""".dedent().trim_prefix("\n") % [
+ _warning("WARNING:"),
+ _nerror(orphans.size()),
+ _build_orphan_node_stacktrace(orphans)
+ ]
+
+
+static func _build_orphan_node_stacktrace(orphans: Array[GdUnitOrphanNodeInfo]) -> String:
+ var stack_trace := "\n"
+ for orphan in orphans:
+ stack_trace += orphan.as_trace(orphan, true) + "\n"
+ return stack_trace.indent(" ")
+
+
+
+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_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), _nerror(current)]
+ Comparator.LESS_THAN:
+ return "%s\n %s but was '%s'" % [_error("Expecting to be less than:"), _colored_value(expected), _nerror(current)]
+ Comparator.LESS_EQUAL:
+ return "%s\n %s but was '%s'" % [_error("Expecting to be less than or equal:"), _colored_value(expected), _nerror(current)]
+ Comparator.GREATER_THAN:
+ return "%s\n %s but was '%s'" % [_error("Expecting to be greater than:"), _colored_value(expected), _nerror(current)]
+ Comparator.GREATER_EQUAL:
+ return "%s\n %s but was '%s'" % [_error("Expecting to be greater than or equal:"), _colored_value(expected), _nerror(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 <null>." % 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 = "<no entry>" 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 += "<DEL>"
+ 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..f1336de
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdAssertMessages.gd.uid
@@ -0,0 +1 @@
+uid://cf4cfpmy5gb5s
diff --git a/addons/gdUnit4/src/asserts/GdAssertReports.gd b/addons/gdUnit4/src/asserts/GdAssertReports.gd
new file mode 100644
index 0000000..06b72ed
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdAssertReports.gd
@@ -0,0 +1,54 @@
+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(message:String, line_number :int) -> void:
+ GdUnitSignals.instance().gdunit_set_test_failed.emit(true)
+ GdAssertReports.set_last_error_line_number(line_number)
+ Engine.set_meta(LAST_ERROR, message)
+ # if we expect to fail we handle as success test
+ if _do_expect_assert_failing():
+ return
+ send_report(GdUnitReport.new().create(GdUnitReport.FAILURE, line_number, message))
+
+
+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..10cae25
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdAssertReports.gd.uid
@@ -0,0 +1 @@
+uid://cnmp765i0d7kk
diff --git a/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd
new file mode 100644
index 0000000..63b45a0
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd
@@ -0,0 +1,424 @@
+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 failure_message() -> String:
+ return _base.failure_message()
+
+
+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 = false) -> 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": "<N/A>"})
+
+ 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": "<N/A>", "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, false, 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, false, compare_mode):
+ return report_success()
+ # check has same elements but in different order
+ if _is_equals_sorted(current_value, expected_value, false, 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, true):
+ @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, true):
+ @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 := false,
+ 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 := false,
+ 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..83db7a3
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://cugrq3kd6cfsd
diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd
new file mode 100644
index 0000000..9f578c4
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd
@@ -0,0 +1,80 @@
+class_name GdUnitAssertImpl
+extends GdUnitAssert
+
+
+var _current :Variant
+var _current_failure_message :String = ""
+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 failure_message() -> String:
+ return _current_failure_message
+
+
+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 line_number := failure_line_number if failure_line_number != -1 else GdUnitAssertions.get_line_number()
+ GdAssertReports.set_last_error_line_number(line_number)
+ _current_failure_message = GdAssertMessages.build_failure_message(failure, _additional_failure_message, _custom_failure_message)
+ GdAssertReports.report_error(_current_failure_message, line_number)
+ Engine.set_meta("GD_TEST_FAILURE", true)
+ return self
+
+
+func do_fail() -> GdUnitAssert:
+ return report_error(GdAssertMessages.error_not_implemented())
+
+
+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..2ca96f7
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://dwhamqtf0w80b
diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertions.gd b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd
new file mode 100644
index 0000000..a5b53c1
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd
@@ -0,0 +1,68 @@
+# 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
+
+
+# Scans the current stack trace for the root cause to extract the line number
+static func get_line_number() -> int:
+ var stack_trace := get_stack()
+ if stack_trace == null or stack_trace.is_empty():
+ return -1
+ for index in stack_trace.size():
+ var stack_info :Dictionary = stack_trace[index]
+ var function :String = stack_info.get("function")
+ # we catch helper asserts to skip over to return the correct line number
+ if function.begins_with("assert_"):
+ continue
+ if function.begins_with("test_"):
+ return stack_info.get("line")
+ var source :String = stack_info.get("source")
+ if source.is_empty() \
+ or source.begins_with("user://") \
+ or source.ends_with("GdUnitAssert.gd") \
+ or source.ends_with("GdUnitAssertions.gd") \
+ or source.ends_with("AssertImpl.gd") \
+ or source.ends_with("GdUnitTestSuite.gd") \
+ or source.ends_with("GdUnitSceneRunnerImpl.gd") \
+ or source.ends_with("GdUnitObjectInteractions.gd") \
+ or source.ends_with("GdUnitObjectInteractionsVerifier.gd") \
+ or source.ends_with("GdUnitAwaiter.gd"):
+ continue
+ return stack_info.get("line")
+ return -1
diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid
new file mode 100644
index 0000000..87e7ca5
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid
@@ -0,0 +1 @@
+uid://cp756mi1gtrjl
diff --git a/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd
new file mode 100644
index 0000000..2fc0ce4
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd
@@ -0,0 +1,87 @@
+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 failure_message() -> String:
+ return _base.failure_message()
+
+
+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..7cecf08
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://dxdyrrpo58p5x
diff --git a/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd
new file mode 100644
index 0000000..c57bbc2
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd
@@ -0,0 +1,207 @@
+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 failure_message() -> String:
+ return _base.failure_message()
+
+
+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, false, 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, false, 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..4ba626d
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://cxrjs0mlg1qr0
diff --git a/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd
new file mode 100644
index 0000000..198624c
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd
@@ -0,0 +1,136 @@
+extends GdUnitFailureAssert
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+var _is_failed := false
+var _failure_message: String
+var _current_failure_message := ""
+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
+ @warning_ignore("unsafe_method_access")
+ _failure_message = current_assert.failure_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_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 line_number := failure_line_number if failure_line_number != -1 else GdUnitAssertions.get_line_number()
+ _current_failure_message = GdAssertMessages.build_failure_message(error_message, _additional_failure_message, _custom_failure_message)
+ GdAssertReports.report_error(_current_failure_message, line_number)
+ 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..cf78a00
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://cr53a1nnhmaqa
diff --git a/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd
new file mode 100644
index 0000000..c4f9570
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd
@@ -0,0 +1,116 @@
+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 failure_message() -> String:
+ return _base.failure_message()
+
+
+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..b8360b7
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://mr5x0e3q4g0a
diff --git a/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd
new file mode 100644
index 0000000..83d7e05
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd
@@ -0,0 +1,159 @@
+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 failure_message() -> String:
+ return _base.failure_message()
+
+
+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..3d0dc07
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://cj7rei3qtlurx
diff --git a/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd
new file mode 100644
index 0000000..c38acf0
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd
@@ -0,0 +1,177 @@
+extends GdUnitFuncAssert
+
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+const DEFAULT_TIMEOUT := 2000
+
+
+var _current_value_provider :ValueProvider
+var _current_failure_message :String = ""
+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 = GdUnitAssertions.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:
+ _current_failure_message = GdAssertMessages.build_failure_message(failure, _additional_failure_message, _custom_failure_message)
+ GdAssertReports.report_error(_current_failure_message, _line_number)
+ return self
+
+
+func failure_message() -> String:
+ return _current_failure_message
+
+
+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..b10c045
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://cd337pgd74ed0
diff --git a/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd
new file mode 100644
index 0000000..ce5503d
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd
@@ -0,0 +1,151 @@
+extends GdUnitGodotErrorAssert
+
+var _current_failure_message := ""
+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 failure_message() -> String:
+ return _current_failure_message
+
+
+func _report_success() -> GdUnitAssert:
+ GdAssertReports.report_success()
+ return self
+
+
+func _report_error(error_message: String, failure_line_number: int = -1) -> GdUnitAssert:
+ var line_number := failure_line_number if failure_line_number != -1 else GdUnitAssertions.get_line_number()
+ _current_failure_message = GdAssertMessages.build_failure_message(error_message, _additional_failure_message, _custom_failure_message)
+ GdAssertReports.report_error(_current_failure_message, line_number)
+ 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..8ecadb7
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://rkf7jw6mw4d4
diff --git a/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd
new file mode 100644
index 0000000..bdee249
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd
@@ -0,0 +1,166 @@
+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 failure_message() -> String:
+ return _base.failure_message()
+
+
+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..512cc48
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://dbms8smcdukg0
diff --git a/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd
new file mode 100644
index 0000000..955e1ef
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd
@@ -0,0 +1,166 @@
+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 failure_message() -> String:
+ return _base.failure_message()
+
+
+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 _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..349ff7b
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://biqm7ejvhb4oc
diff --git a/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd
new file mode 100644
index 0000000..98a6768
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd
@@ -0,0 +1,128 @@
+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 failure_message() -> String:
+ return _base.failure_message()
+
+
+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("<null>", 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..6ba023e
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://bpeci78q50dtq
diff --git a/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd
new file mode 100644
index 0000000..0da696a
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd
@@ -0,0 +1,172 @@
+extends GdUnitSignalAssert
+
+const DEFAULT_TIMEOUT := 2000
+
+var _signal_collector :GdUnitSignalCollector
+var _emitter :Object
+var _current_failure_message :String = ""
+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 = GdUnitAssertions.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, GdUnitAssertions.get_line_number())
+ return self
+
+
+func report_error(failure :String) -> GdUnitAssert:
+ _current_failure_message = GdAssertMessages.build_failure_message(failure, _additional_failure_message, _custom_failure_message)
+ GdAssertReports.report_error(_current_failure_message, _line_number)
+ return self
+
+
+func failure_message() -> String:
+ return _current_failure_message
+
+
+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 = GdUnitAssertions.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 = GdUnitAssertions.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..3690588
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://n33x4jg0yjqh
diff --git a/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd
new file mode 100644
index 0000000..cdbcdff
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd
@@ -0,0 +1,208 @@
+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 failure_message() -> String:
+ return _base.failure_message()
+
+
+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, false, GdAssertMessages.error_equal)
+
+
+func is_equal_ignoring_case(expected: Variant) -> GdUnitStringAssert:
+ return _is_equal(expected, true, GdAssertMessages.error_equal_ignoring_case)
+
+
+@warning_ignore_start("unsafe_call_argument")
+func _is_equal(expected: Variant, ignore_case: 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, ignore_case):
+ 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, true):
+ 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..d257a25
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://cp0rvxafx8aqw
diff --git a/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd
new file mode 100644
index 0000000..fbc031a
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd
@@ -0,0 +1,187 @@
+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 failure_message() -> String:
+ return _base.failure_message()
+
+
+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..4f08785
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://baa45s62kgmhi
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..f9e818d
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/ValueProvider.gd.uid
@@ -0,0 +1 @@
+uid://fh2vdv250867
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..978c595
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdArgumentParser.gd.uid
@@ -0,0 +1 @@
+uid://v2okve7k32hr
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..28437db
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdCommand.gd.uid
@@ -0,0 +1 @@
+uid://detl386jrd80u
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:<cmd_name>:String and value: [<cb single arg>, <cb multible args>]: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..b0fa79a
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdCommandHandler.gd.uid
@@ -0,0 +1 @@
+uid://2q800f4gbor2
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..e4717b6
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdOption.gd.uid
@@ -0,0 +1 @@
+uid://cjlyjf7drcjtf
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..6e0c2c7
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdOptions.gd.uid
@@ -0,0 +1 @@
+uid://bcrrg66r74ryx
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(<array>) 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 "<null>"
+ @warning_ignore("unsafe_cast")
+ if (elements as Array).is_empty():
+ return "<empty>"
+ 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..ca8fecb
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdArrayTools.gd.uid
@@ -0,0 +1 @@
+uid://ojsvg5sbjqmr
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 += "<SPAN>" + lcsList[k] + " </SPAN>"
+ 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 += "<SPAN style='BACKGROUND-COLOR:" + deleteColor.to_html() + "'>" + text1Words[i] + " </SPAN>"
+ i += 1
+ else: if text2Words[j] != lcsList[k]:
+ while j < text2Words.size() and text2Words[j] != lcsList[k]:
+ stringBuffer += "<SPAN style='BACKGROUND-COLOR:" + insertColor.to_html() + "'>" + text2Words[j] + " </SPAN>"
+ j += 1
+ i = word1LastIndex
+ j = word2LastIndex
+
+ while word1LastIndex < text1Words.size():
+ stringBuffer += "<SPAN style='BACKGROUND-COLOR:" + deleteColor.to_html() + "'>" + text1Words[word1LastIndex] + " </SPAN>"
+ word1LastIndex += 1
+ while word2LastIndex < text2Words.size():
+ stringBuffer += "<SPAN style='BACKGROUND-COLOR:" + insertColor.to_html() + "'>" + text2Words[word2LastIndex] + " </SPAN>"
+ 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..7b60098
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdDiffTool.gd.uid
@@ -0,0 +1 @@
+uid://ccj7h0qml5bng
diff --git a/addons/gdUnit4/src/core/GdObjects.gd b/addons/gdUnit4/src/core/GdObjects.gd
new file mode 100644
index 0000000..279e518
--- /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 = false, 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 = false, 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:
+ @warning_ignore("unsafe_method_access")
+ return obj_a.to_lower() == obj_b.to_lower()
+ else:
+ return obj_a == obj_b
+ 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 <clazz_name>
+# 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 <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..dc1034b
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdObjects.gd.uid
@@ -0,0 +1 @@
+uid://cfk1e01rfphf0
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..22beb73
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnit4Version.gd.uid
@@ -0,0 +1 @@
+uid://dm2llfcfmgmox
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. <prefix_%d>
+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..91c595c
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid
@@ -0,0 +1 @@
+uid://cqbvlw7jnsrm8
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..e1220b6
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitProperty.gd.uid
@@ -0,0 +1 @@
+uid://bcfqsjp4j23io
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..52ea4fb
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitResult.gd.uid
@@ -0,0 +1 @@
+uid://v42xavdbquis
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..f35bac7
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid
@@ -0,0 +1 @@
+uid://c4624ta2v365l
diff --git a/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd
new file mode 100644
index 0000000..5c85252
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd
@@ -0,0 +1,642 @@
+# 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 _saved_iterations_per_second: float
+var _scene_auto_free := false
+
+
+func _init(p_scene: Variant, p_verbose: bool, p_hide_push_errors := false) -> void:
+ _verbose = p_verbose
+ _saved_iterations_per_second = Engine.get_physics_ticks_per_second()
+ @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" % (_saved_iterations_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((_saved_iterations_per_second * _time_factor) as int)
+
+
+func __deactivate_time_factor() -> void:
+ Engine.set_time_scale(1)
+ Engine.set_physics_ticks_per_second(_saved_iterations_per_second as int)
+
+
+# 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..a758b3b
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid
@@ -0,0 +1 @@
+uid://qogbhj2t2ngs
diff --git a/addons/gdUnit4/src/core/GdUnitSettings.gd b/addons/gdUnit4/src/core/GdUnitSettings.gd
new file mode 100644
index 0000000..c4eaa96
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSettings.gd
@@ -0,0 +1,443 @@
+@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"
+
+
+# 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"
+
+
+# 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 := "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,
+}
+
+
+const _VALUE_SET_SEPARATOR = "\f" # ASCII Form-feed character (AKA page break)
+
+
+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.")
+ # 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])
+ migrate_properties()
+
+
+static func migrate_properties() -> void:
+ var TEST_ROOT_FOLDER := "gdunit4/settings/test/test_root_folder"
+ if get_property(TEST_ROOT_FOLDER) != null:
+ migrate_property(TEST_ROOT_FOLDER,\
+ TEST_LOOKUP_FOLDER,\
+ DEFAULT_TEST_LOOKUP_FOLDER,\
+ HELP_TEST_LOOKUP_FOLDER,\
+ func(value :Variant) -> String: return DEFAULT_TEST_LOOKUP_FOLDER if value == null else value)
+
+
+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(name :String, default :Variant, help :="", value_set := PackedStringArray()) -> void:
+ if not ProjectSettings.has_setting(name):
+ #prints("GdUnit4: Set inital settings '%s' to '%s'." % [name, str(default)])
+ ProjectSettings.set_setting(name, default)
+
+ ProjectSettings.set_initial_value(name, default)
+ help = help if value_set.is_empty() else "%s%s%s" % [help, _VALUE_SET_SEPARATOR, value_set]
+ set_help(name, default, help)
+
+
+static func set_help(property_name :String, value :Variant, help :String) -> void:
+ ProjectSettings.add_property_info({
+ "name": property_name,
+ "type": typeof(value),
+ "hint": PROPERTY_HINT_TYPE_STRING,
+ "hint_string": help
+ })
+
+
+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)
+
+
+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 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 extract_value_set_from_help(value :String) -> PackedStringArray:
+ var split_value := value.split(_VALUE_SET_SEPARATOR)
+ if not split_value.size() > 1:
+ return PackedStringArray()
+
+ var regex := RegEx.new()
+ @warning_ignore("return_value_discarded")
+ regex.compile("\\[(.+)\\]")
+ var matches := regex.search_all(split_value[1])
+ if matches.is_empty():
+ return PackedStringArray()
+ var values: String = matches[0].get_string(1)
+ return values.replacen(" ", "").replacen("\"", "").split(",", false)
+
+
+static func extract_help_text(value :String) -> String:
+ return value.split(_VALUE_SET_SEPARATOR)[0]
+
+
+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 help: String = property["hint_string"]
+ var value_set := extract_value_set_from_help(help)
+ return GdUnitProperty.new(property_name, value_type, value, default, extract_help_text(help), 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_help(new_property, value, help)
+ ProjectSettings.clear(old_property)
+ prints("Successfully migrated property '%s' -> '%s' value: %s" % [old_property, new_property, value])
+
+
+static func dump_to_tmp() -> void:
+ @warning_ignore("return_value_discarded")
+ ProjectSettings.save_custom("user://project_settings.godot")
+
+
+static func restore_dump_from_tmp() -> void:
+ @warning_ignore("return_value_discarded")
+ DirAccess.copy_absolute("user://project_settings.godot", "res://project.godot")
diff --git a/addons/gdUnit4/src/core/GdUnitSettings.gd.uid b/addons/gdUnit4/src/core/GdUnitSettings.gd.uid
new file mode 100644
index 0000000..3fcb866
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSettings.gd.uid
@@ -0,0 +1 @@
+uid://dvy5w5kr2i6e6
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..366e962
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid
@@ -0,0 +1 @@
+uid://ch6tenrmpno8w
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<Object> : {
+# signal_name<String> : [signal_args<Array>],
+# ...
+# }
+# }
+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..628debc
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid
@@ -0,0 +1 @@
+uid://dgwkonrctse1
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..d4ca8db
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSignals.gd.uid
@@ -0,0 +1 @@
+uid://071nom6h2642
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..d54a18d
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSingleton.gd.uid
@@ -0,0 +1 @@
+uid://b5fwmspo4wq8k
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..dbf6706
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid
@@ -0,0 +1 @@
+uid://dth63om1kgmwc
diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd
new file mode 100644
index 0000000..97a49b0
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd
@@ -0,0 +1,20 @@
+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")
+ ScriptEditorControls.save_an_open_script(source.resource_path)
+ @warning_ignore("return_value_discarded")
+ ScriptEditorControls.save_an_open_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..7143f81
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid
@@ -0,0 +1 @@
+uid://bxpdkva8owdce
diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd
new file mode 100644
index 0000000..345b7ed
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd
@@ -0,0 +1,410 @@
+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 from test method
+ var test_attribute := _build_test_attribute(script, fd)
+ # 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, fd))
+ return test_suite
+
+
+func _build_test_attribute(script: GDScript, fd: GdFunctionDescriptor) -> TestCaseAttribute:
+ var collected_unknown_aruments := PackedStringArray()
+ var attribute := TestCaseAttribute.new()
+
+ # Collect test attributes
+ for arg: GdFunctionArgument in fd.args():
+ if arg.type() == GdObjects.TYPE_FUZZER:
+ attribute.fuzzers.append(arg)
+ else:
+ # We allow underscore as prefix to prevent unused argument warnings
+ match arg.name().trim_prefix("_"):
+ 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"
+
+
+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 "-<Unexpected>-"
+
+
+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..223b357
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid
@@ -0,0 +1 @@
+uid://crsmt5amypl65
diff --git a/addons/gdUnit4/src/core/GdUnitTools.gd b/addons/gdUnit4/src/core/GdUnitTools.gd
new file mode 100644
index 0000000..0742896
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTools.gd
@@ -0,0 +1,144 @@
+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:
+ 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..af02f0d
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTools.gd.uid
@@ -0,0 +1 @@
+uid://wmir42v6nqmf
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..5b874df
--- /dev/null
+++ b/addons/gdUnit4/src/core/GodotVersionFixures.gd.uid
@@ -0,0 +1 @@
+uid://b0kdldwgufklw
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..ef275de
--- /dev/null
+++ b/addons/gdUnit4/src/core/LocalTime.gd.uid
@@ -0,0 +1 @@
+uid://dq6srj6me3mjo
diff --git a/addons/gdUnit4/src/core/_TestCase.gd b/addons/gdUnit4/src/core/_TestCase.gd
new file mode 100644
index 0000000..2ef07dc
--- /dev/null
+++ b/addons/gdUnit4/src/core/_TestCase.gd
@@ -0,0 +1,253 @@
+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_set_resolver: GdUnitTestParameterSetResolver
+var _is_disposed := false
+var _func_state: Variant
+
+
+func _init(test_case: GdUnitTestCase, attribute: TestCaseAttribute, fd: GdFunctionDescriptor) -> void:
+ _test_case = test_case
+ _attribute = attribute
+ set_function_descriptor(fd)
+
+
+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()
+
+ # Resolve parameter set at runtime to include runtime variables
+ var test_parameters := await _resolve_test_parameters(_test_case.attribute_index)
+ if test_parameters.is_empty():
+ return
+
+ await _execute_test_case(test_name(), test_parameters)
+
+
+func _resolve_test_parameters(attribute_index: int) -> Array:
+ var result := _parameter_set_resolver.load_parameter_sets(get_parent())
+ if result.is_error():
+ do_skip(true, result.error_message())
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ completed.emit()
+ return []
+
+ # validate the parameter set
+ var parameter_sets: Array = result.value()
+ result = _parameter_set_resolver.validate(parameter_sets, attribute_index)
+ if result.is_error():
+ do_skip(true, result.error_message())
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ completed.emit()
+ return []
+
+ @warning_ignore("unsafe_method_access")
+ var test_parameters: Array = parameter_sets[attribute_index].duplicate()
+ # We need here to add a empty array to override the `test_parameters` to prevent initial "default" parameters from being used.
+ # This prevents objects in the argument list from being unnecessarily re-instantiated.
+ test_parameters.append([])
+
+ return test_parameters
+
+
+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
+ # needs at least on await otherwise it breaks the awaiting chain
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ 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)))
+ completed.emit()
+
+
+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()))
+ completed.emit()
+
+
+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_set_resolver.is_parameterized()
+
+
+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 set_function_descriptor(fd: GdFunctionDescriptor) -> void:
+ _parameter_set_resolver = GdUnitTestParameterSetResolver.new(fd)
+
+
+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..5c7e8b9
--- /dev/null
+++ b/addons/gdUnit4/src/core/_TestCase.gd.uid
@@ -0,0 +1 @@
+uid://byae5d27sl1jq
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
--- /dev/null
+++ b/addons/gdUnit4/src/core/assets/touch-button.png
Binary files 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..4122b48
--- /dev/null
+++ b/addons/gdUnit4/src/core/assets/touch-button.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dhtfmfhdtq4qr"
+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..653a941
--- /dev/null
+++ b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid
@@ -0,0 +1 @@
+uid://cno711hbnapj1
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..da0e452
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid
@@ -0,0 +1 @@
+uid://bbetqeco0l3rj
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..fd5b2c3
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid
@@ -0,0 +1 @@
+uid://lo83wc48tvgp
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..24e7925
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid
@@ -0,0 +1 @@
+uid://beoj16ng5y3di
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..62b55e6
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid
@@ -0,0 +1 @@
+uid://iq85jvyaa48b
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd
new file mode 100644
index 0000000..8e2b2f3
--- /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 main scene manually
+ 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 [<command_id, args...>]")
+ 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..1a34d39
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid
@@ -0,0 +1 @@
+uid://m5es1m3vrm50
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd
new file mode 100644
index 0000000..cb4a634
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd
@@ -0,0 +1,27 @@
+class_name GdUnitCommandInspectorDebugTests
+extends GdUnitBaseCommand
+
+const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.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: InspectorTreeMainPanel = 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..fd8150f
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid
@@ -0,0 +1 @@
+uid://c7qpkaykv4f4a
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd
new file mode 100644
index 0000000..e2ec5e4
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd
@@ -0,0 +1,57 @@
+class_name GdUnitCommandInspectorRerunTestsUntilFailure
+extends GdUnitBaseCommand
+
+
+signal session_closed()
+
+
+const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.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: InspectorTreeMainPanel = 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..d589f76
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid
@@ -0,0 +1 @@
+uid://d0pvhydsl86mf
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd
new file mode 100644
index 0000000..c0cb733
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd
@@ -0,0 +1,26 @@
+class_name GdUnitCommandInspectorRunTests
+extends GdUnitBaseCommand
+
+const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.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: InspectorTreeMainPanel = 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..2d3aee1
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid
@@ -0,0 +1 @@
+uid://dcrjckjo7ur1g
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd
new file mode 100644
index 0000000..fa784f8
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd
@@ -0,0 +1,26 @@
+class_name GdUnitCommandInspectorTreeCollapse
+extends GdUnitBaseCommand
+
+const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.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: InspectorTreeMainPanel = 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..810921d
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid
@@ -0,0 +1 @@
+uid://c1loc8h46b4m0
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd
new file mode 100644
index 0000000..76f8d0b
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd
@@ -0,0 +1,25 @@
+class_name GdUnitCommandInspectorTreeExpand
+extends GdUnitBaseCommand
+
+const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.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: InspectorTreeMainPanel = 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..4f3fad5
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid
@@ -0,0 +1 @@
+uid://dc1uaxrnrvesy
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..4e38afe
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid
@@ -0,0 +1 @@
+uid://b2oyb1jkqagjc
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..619f309
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid
@@ -0,0 +1 @@
+uid://b1ftsa2a1ba1i
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd
new file mode 100644
index 0000000..17876ca
--- /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")
+ ScriptEditorControls.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..fb6d1f9
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid
@@ -0,0 +1 @@
+uid://dikmhnnpgxyhs
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..e575fb8
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid
@@ -0,0 +1 @@
+uid://bthuj4vyx2erm
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..4889e26
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid
@@ -0,0 +1 @@
+uid://c5w4h304l54c6
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..a799bf6
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid
@@ -0,0 +1 @@
+uid://b33mmwenpaj4m
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd
new file mode 100644
index 0000000..53d74ed
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd
@@ -0,0 +1,124 @@
+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
+
+
+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 and EditorInterface.is_playing_scene():
+ EditorInterface.stop_playing_scene()
+ # We need finaly to send the test session close event because the current run is hard aborted.
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionClose.new())
+ elif OS.is_process_running(_current_runner_process_id):
+ var result := OS.kill(_current_runner_process_id)
+ if result != OK:
+ push_error("ERROR checked stopping GdUnit Test Runner. error code: %s" % 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())
+
+
+## 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
+ ScriptEditorControls.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..8e395e2
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid
@@ -0,0 +1 @@
+uid://camw8m61ljvpl
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..841bcaf
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid
@@ -0,0 +1 @@
+uid://tyumhj8vfdaf
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..78730f9
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid
@@ -0,0 +1 @@
+uid://caisrhebqar04
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd
new file mode 100644
index 0000000..766f9ea
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd
@@ -0,0 +1,125 @@
+## 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..0edbaff
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid
@@ -0,0 +1 @@
+uid://ct8h4c11708ha
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..0d8b757
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid
@@ -0,0 +1 @@
+uid://dnyw8fm7vfc6i
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..841f240
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid
@@ -0,0 +1 @@
+uid://jf2a2cjlqkkr
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd
new file mode 100644
index 0000000..cb3e407
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd
@@ -0,0 +1,171 @@
+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 filter_tests(method: Dictionary) -> bool:
+ var method_name: String = method["name"]
+ return method_name.begins_with("test_")
+
+
+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:
+ var test_names := source_script.get_script_method_list()\
+ .filter(filter_tests)\
+ .map(func(method: Dictionary) -> String: return method["name"])
+ # no tests discovered?
+ if test_names.is_empty():
+ return
+
+ var parser := GdScriptParser.new()
+ var fds := parser.get_function_descriptors(source_script as GDScript, test_names)
+ for fd in fds:
+ var resolver := GdFunctionParameterSetResolver.new(fd)
+ for test_case in resolver.resolve_test_cases(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 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..c505a79
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid
@@ -0,0 +1 @@
+uid://dr8h7h5duuybc
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..b01c4d3
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid
@@ -0,0 +1 @@
+uid://be80qqix7cxt8
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..a18f4e5
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid
@@ -0,0 +1 @@
+uid://cn0cbj8cbhp32
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..7e32a4d
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid
@@ -0,0 +1 @@
+uid://v7dkld783gk8
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..3d26683
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid
@@ -0,0 +1 @@
+uid://cw044tcedgm0a
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..5478eb1
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid
@@ -0,0 +1 @@
+uid://dfktvv2e63sor
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..cdfdae2
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid
@@ -0,0 +1 @@
+uid://bj0o8343e00i0
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..8dc2be5
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid
@@ -0,0 +1 @@
+uid://ctm8r6mjxxo18
diff --git a/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd
new file mode 100644
index 0000000..844561b
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd
@@ -0,0 +1,274 @@
+## 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 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 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 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 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()
+
+ match(gc_orphan_check):
+ GC_ORPHANS_CHECK.SUITE_HOOK_AFTER:
+ _orphan_monitor.collect()
+ var orphan_infos := _orphan_monitor.detected_orphans()
+ if orphan_infos.is_empty():
+ return
+ reports().push_front(GdUnitReport.new() \
+ .create(GdUnitReport.ORPHAN, 1, GdAssertMessages.orphan_detected_on_suite_setup(orphan_infos))
+ .with_current_value(orphan_infos.size()))
+
+ GC_ORPHANS_CHECK.TEST_HOOK_AFTER:
+ _orphan_monitor.collect()
+ var orphans := _orphan_monitor.detected_orphans()
+ if not orphans.is_empty():
+ reports().push_front(GdUnitReport.new()\
+ .create(GdUnitReport.ORPHAN, 1, GdAssertMessages.orphan_detected_on_test_setup(orphans))
+ .with_current_value(orphans.size()))
+
+ GC_ORPHANS_CHECK.TEST_CASE:
+ var orphans := _orphan_monitor.detected_orphans()
+ if orphans.is_empty():
+ var orphans_count := _orphan_monitor.orphans_count()
+ if orphans_count > 0:
+ reports().push_front(GdUnitReport.new() \
+ .create(GdUnitReport.ORPHAN, test_case.line_number(), GdAssertMessages.orphan_warning(orphans_count))
+ .with_current_value(orphans_count))
+ else:
+ reports().push_front(GdUnitReport.new()\
+ .create(GdUnitReport.ORPHAN, test_case.line_number(), GdAssertMessages.orphan_detected_on_test(orphans))
+ .with_current_value(orphans.size()))
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..98ede87
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid
@@ -0,0 +1 @@
+uid://doxksl3xmwd4t
diff --git a/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd
new file mode 100644
index 0000000..dd03a31
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd
@@ -0,0 +1,135 @@
+## 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
+ # give engine time to free objects to process objects marked by queue_free()
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ 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..35465f8
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid
@@ -0,0 +1 @@
+uid://3kyvv6qv76xy
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..3c1601f
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid
@@ -0,0 +1 @@
+uid://5dmb52xvknlt
diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd
new file mode 100644
index 0000000..dfb4441
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd
@@ -0,0 +1,56 @@
+## 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)
+ 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..6717eba
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid
@@ -0,0 +1 @@
+uid://cfluc4qh1cia6
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..f106f21
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd
@@ -0,0 +1,22 @@
+## 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()
+
+ 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..f241c9f
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid
@@ -0,0 +1 @@
+uid://cry2ikys4ww2x
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..2073bc3
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid
@@ -0,0 +1 @@
+uid://lqofqnr401s3
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..473f1e2
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd
@@ -0,0 +1,37 @@
+## 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_<name>()'.[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
+
+ 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()
+
+ # 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..7105242
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid
@@ -0,0 +1 @@
+uid://bnx6cbvnp5pr1
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..1b19c2a
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid
@@ -0,0 +1 @@
+uid://mwtvw184h4nk
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..2fdfdc3
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid
@@ -0,0 +1 @@
+uid://fx62snxk32g4
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..07e3779
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd
@@ -0,0 +1,147 @@
+## 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:
+ @warning_ignore("return_value_discarded")
+ GdUnitMemoryObserver.guard_instance(context.test_suite.__awaiter)
+ 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)
+ 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..f250acb
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid
@@ -0,0 +1 @@
+uid://c00afkws3ewfx
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(<GdUnitExecutionContext>)
+## [/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..5ae6f69
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid
@@ -0,0 +1 @@
+uid://bfvoxsa3oad2k
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..c1e0924
--- /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
+
+ 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..7444e55
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid
@@ -0,0 +1 @@
+uid://buqq7wmo7x6de
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..62dda37
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd
@@ -0,0 +1,55 @@
+## 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_<name>(<fuzzer>)' 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..5df0ddb
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid
@@ -0,0 +1 @@
+uid://b3tpfwvmfjkoq
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..03f8fa5
--- /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
+
+ 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..9a0459d
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid
@@ -0,0 +1 @@
+uid://ctjx74gak008y
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..b8ace50
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd
@@ -0,0 +1,11 @@
+## The single test case execution stage.[br]
+class_name GdUnitTestCaseSingleTestStage
+extends IGdUnitExecutionStage
+
+
+## Executes a single test case 'test_<name>()'.[br]
+## It executes synchronized following stages[br]
+## -> test_case() [br]
+func _execute(context: GdUnitExecutionContext) -> void:
+ await context.test_case.execute()
+ await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_CASE)
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..48f2c97
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid
@@ -0,0 +1 @@
+uid://66t5ejuybo1s
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..94a488c
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid
@@ -0,0 +1 @@
+uid://dba8xduvhy4hd
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..6af94d5
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid
@@ -0,0 +1 @@
+uid://dsvmfyc8bxff8
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..719a67e
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid
@@ -0,0 +1 @@
+uid://cd4t5sc3jv2i7
diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd
new file mode 100644
index 0000000..5a9bdcb
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd
@@ -0,0 +1,191 @@
+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_hook: GdUnitTestSessionHook = enigne_hooks.filter(func(element: GdUnitTestSessionHook) -> bool:
+ return element.get_script().resource_path == hock_path
+ ).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..690538d
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid
@@ -0,0 +1 @@
+uid://bp5t2wb0depb6
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..42d6bff
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid
@@ -0,0 +1 @@
+uid://btuob2k5s80l
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..759729e
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid
@@ -0,0 +1 @@
+uid://baic1omvtg71r
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 "<empty>"
+ # 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..0f8e9c1
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid
@@ -0,0 +1 @@
+uid://dl0wf0ieoppil
diff --git a/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd
new file mode 100644
index 0000000..bd38eb2
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd
@@ -0,0 +1,208 @@
+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"]
+
+static var _fuzzer_regex: RegEx
+static var _cleanup_leading_spaces: RegEx
+static var _fix_comma_space: RegEx
+
+var _name: String
+var _type: int
+var _type_hint: int
+var _default_value: Variant
+var _parameter_sets: PackedStringArray = []
+
+
+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_parameter_set(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_parameter_set(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
+
+
+func _parse_parameter_set(input :String) -> PackedStringArray:
+ if not input.contains("["):
+ return []
+
+ input = _cleanup_leading_spaces.sub(input, "", true)
+ input = input.replace("\n", "").strip_edges().trim_prefix("[").trim_suffix("]").trim_prefix("]")
+ var single_quote := false
+ var double_quote := false
+ var array_end := 0
+ var current_index := 0
+ var output :PackedStringArray = []
+ var buf := input.to_utf8_buffer()
+ var collected_characters: = PackedByteArray()
+ var matched :bool = false
+
+ for c in buf:
+ current_index += 1
+ matched = current_index == buf.size()
+ @warning_ignore("return_value_discarded")
+ collected_characters.push_back(c)
+
+ match c:
+ # ' ': ignore spaces between array elements
+ 32: if array_end == 0 and (not double_quote and not single_quote):
+ collected_characters.remove_at(collected_characters.size()-1)
+ # ',': step over array element seperator ','
+ 44: if array_end == 0:
+ matched = true
+ collected_characters.remove_at(collected_characters.size()-1)
+ # '`':
+ 39: single_quote = !single_quote
+ # '"':
+ 34: if not single_quote: double_quote = !double_quote
+ # '['
+ 91: if not double_quote and not single_quote: array_end +=1 # counts array open
+ # ']'
+ 93: if not double_quote and not single_quote: array_end -=1 # counts array closed
+
+ # if array closed than collect the element
+ if matched:
+ var parameters := _fix_comma_space.sub(collected_characters.get_string_from_utf8(), ", ", true)
+ if not parameters.is_empty():
+ @warning_ignore("return_value_discarded")
+ output.append(parameters)
+ collected_characters.clear()
+ matched = false
+ 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..b98b975
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid
@@ -0,0 +1 @@
+uid://dtwu3dpeq6huv
diff --git a/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd
new file mode 100644
index 0000000..7eae4b2
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd
@@ -0,0 +1,286 @@
+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 _line_number :int
+var _return_type :int
+var _return_class :String
+var _args : Array[GdFunctionArgument]
+var _varargs :Array[GdFunctionArgument]
+
+
+
+static func create(p_name: String, p_source_path: String, p_source_line: int, p_return_type: int, p_args: Array[GdFunctionArgument] = []) -> GdFunctionDescriptor:
+ var fd := GdFunctionDescriptor.new(p_name, p_source_line, false, false, false, p_return_type, "", p_args)
+ fd.enrich_file_info(p_source_path, p_source_line)
+ return fd
+
+static func create_static(p_name: String, p_source_path: String, p_source_line: int, p_return_type: int, p_args: Array[GdFunctionArgument] = []) -> GdFunctionDescriptor:
+ var fd := GdFunctionDescriptor.new(p_name, p_source_line, false, true, false, p_return_type, "", p_args)
+ fd.enrich_file_info(p_source_path, p_source_line)
+ return fd
+
+
+func _init(p_name :String,
+ p_line_number :int,
+ 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
+ _line_number = p_line_number
+ _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 line_number() -> int:
+ return _line_number
+
+
+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_line_number: int) -> void:
+ _source_path = p_source_path
+ _line_number = p_line_number
+
+
+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:%s] func %s(%s):" % [line_number(), name(), typed_args()]
+ var func_template := fsignature + "[Line:%s] func %s(%s) -> %s:"
+ if is_static():
+ func_template= "[Line:%s] static func %s(%s) -> %s:"
+ return func_template % [line_number(), 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
+
+ return GdFunctionDescriptor.new(
+ func_name,
+ -1,
+ is_virtual_,
+ is_static_,
+ is_engine_,
+ _extract_return_type(return_descriptor),
+ 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 type == TYPE_INT and usage & PROPERTY_USAGE_CLASS_IS_ENUM:
+ return GdObjects.TYPE_ENUM
+ if type == TYPE_NIL and usage & PROPERTY_USAGE_NIL_IS_VARIANT:
+ return GdObjects.TYPE_VARIANT
+ if type == TYPE_NIL and usage == 6:
+ 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)
+ #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 _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_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..0396b95
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid
@@ -0,0 +1 @@
+uid://csn6e1csw3fry
diff --git a/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd
new file mode 100644
index 0000000..9c45e62
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd
@@ -0,0 +1,188 @@
+class_name GdFunctionParameterSetResolver
+extends RefCounted
+
+const CLASS_TEMPLATE = """
+class_name _ParameterExtractor extends '${clazz_path}'
+
+func __extract_test_parameters() -> Array:
+ return ${test_params}
+
+"""
+
+const EXCLUDE_PROPERTIES_TO_COPY = [
+ "script",
+ "type",
+ "Node",
+ "_import_path"]
+
+
+var _fd: GdFunctionDescriptor
+var _static_sets_by_index := {}
+var _is_static := true
+
+func _init(fd: GdFunctionDescriptor) -> void:
+ _fd = fd
+
+
+func resolve_test_cases(script: GDScript) -> Array[GdUnitTestCase]:
+ if not is_parameterized():
+ return [GdUnitTestCase.from(script.resource_path, _fd.source_path(), _fd.line_number(), _fd.name())]
+ return extract_test_cases_by_reflection(script)
+
+
+func is_parameterized() -> bool:
+ return _fd.is_parameterized()
+
+
+func is_parameter_sets_static() -> bool:
+ return _is_static
+
+
+func is_parameter_set_static(index: int) -> bool:
+ return _is_static and _static_sets_by_index.get(index, false)
+
+
+# validates the given arguments are complete and matches to required input fields of the test function
+func validate(input_value_set: Array) -> String:
+ var input_arguments := _fd.args()
+ # check given parameter set with test case arguments
+ var expected_arg_count := input_arguments.size() - 1
+ for input_values :Variant in input_value_set:
+ var parameter_set_index := input_value_set.find(input_values)
+ if input_values is Array:
+ var arr_values: Array = input_values
+ var current_arg_count := arr_values.size()
+ if current_arg_count != expected_arg_count:
+ return "\n The parameter set at index [%d] does not match the expected input parameters!\n The test case requires [%d] input parameters, but the set contains [%d]" % [parameter_set_index, expected_arg_count, current_arg_count]
+ var error := validate_parameter_types(input_arguments, arr_values, parameter_set_index)
+ if not error.is_empty():
+ return error
+ else:
+ return "\n The parameter set at index [%d] does not match the expected input parameters!\n Expecting an array of input values." % parameter_set_index
+ return ""
+
+
+static func validate_parameter_types(input_arguments: Array, input_values: Array, parameter_set_index: int) -> String:
+ for i in input_arguments.size():
+ var input_param: GdFunctionArgument = input_arguments[i]
+ # only check the test input arguments
+ if input_param.is_parameter_set():
+ continue
+ var input_param_type := input_param.type()
+ var input_value :Variant = input_values[i]
+ var input_value_type := typeof(input_value)
+ # input parameter is not typed or is Variant we skip the type test
+ if input_param_type == TYPE_NIL or input_param_type == GdObjects.TYPE_VARIANT:
+ continue
+ # is input type enum allow int values
+ if input_param_type == GdObjects.TYPE_VARIANT and input_value_type == TYPE_INT:
+ continue
+ # allow only equal types and object == null
+ if input_param_type == TYPE_OBJECT and input_value_type == TYPE_NIL:
+ continue
+ if input_param_type != input_value_type:
+ return "\n The parameter set at index [%d] does not match the expected input parameters!\n The value '%s' does not match the required input parameter <%s>." % [parameter_set_index, input_value, input_param]
+ return ""
+
+
+func extract_test_cases_by_reflection(script: GDScript) -> Array[GdUnitTestCase]:
+ var source: Node = script.new()
+ source.queue_free()
+
+ var fa := GdFunctionArgument.get_parameter_set(_fd.args())
+ var parameter_sets := fa.parameter_sets()
+ # if no parameter set detected we need to resolve it by using reflection
+ if parameter_sets.size() == 0:
+ _is_static = false
+ return _extract_test_cases_by_reflection(source, script)
+ else:
+ var test_cases: Array[GdUnitTestCase] = []
+ var property_names := _extract_property_names(source)
+ for parameter_set_index in parameter_sets.size():
+ var parameter_set := parameter_sets[parameter_set_index]
+ _static_sets_by_index[parameter_set_index] = _is_static_parameter_set(parameter_set, property_names)
+ @warning_ignore("return_value_discarded")
+ test_cases.append(GdUnitTestCase.from(script.resource_path, _fd.source_path(), _fd.line_number(), _fd.name(), parameter_set_index, parameter_set))
+ parameter_set_index += 1
+ return test_cases
+
+
+func _extract_property_names(source: Node) -> PackedStringArray:
+ return source.get_property_list()\
+ .map(func(property :Dictionary) -> String: return property["name"])\
+ .filter(func(property :String) -> bool: return !EXCLUDE_PROPERTIES_TO_COPY.has(property))
+
+
+# tests if the test property set contains an property reference by name, if not the parameter set holds only static values
+func _is_static_parameter_set(parameters :String, property_names :PackedStringArray) -> bool:
+ for property_name in property_names:
+ if parameters.contains(property_name):
+ _is_static = false
+ return false
+ return true
+
+
+func _extract_test_cases_by_reflection(source: Node, script: GDScript) -> Array[GdUnitTestCase]:
+ var parameter_sets := load_parameter_sets(source)
+ var test_cases: Array[GdUnitTestCase] = []
+ for index in parameter_sets.size():
+ var parameter_set := str(parameter_sets[index])
+ @warning_ignore("return_value_discarded")
+ test_cases.append(GdUnitTestCase.from(script.resource_path, _fd.source_path(), _fd.line_number(), _fd.name(), index, parameter_set))
+ return test_cases
+
+
+# extracts the arguments from the given test case, using kind of reflection solution
+# to restore the parameters from a string representation to real instance type
+func load_parameter_sets(source: Node) -> Array:
+ var source_script: GDScript = source.get_script()
+ var parameter_arg := GdFunctionArgument.get_parameter_set(_fd.args())
+ var source_code := CLASS_TEMPLATE \
+ .replace("${clazz_path}", source_script.resource_path) \
+ .replace("${test_params}", parameter_arg.value_as_string())
+ var script := GDScript.new()
+ script.source_code = source_code
+ # enable this lines only for debuging
+ #script.resource_path = GdUnitFileAccess.create_temp_dir("parameter_extract") + "/%s__.gd" % test_case.get_name()
+ #DirAccess.remove_absolute(script.resource_path)
+ #ResourceSaver.save(script, script.resource_path)
+ var result := script.reload()
+ if result != OK:
+ push_error("Extracting test parameters failed! Script loading error: %s" % result)
+ return []
+ var instance: Node = script.new()
+ GdFunctionParameterSetResolver.copy_properties(source, instance)
+ instance.queue_free()
+ var parameter_sets: Array = instance.call("__extract_test_parameters")
+ return fixure_typed_parameters(parameter_sets, _fd.args())
+
+
+func fixure_typed_parameters(parameter_sets: Array, arg_descriptors: Array[GdFunctionArgument]) -> Array:
+ for parameter_set_index in parameter_sets.size():
+ var parameter_set: Array = parameter_sets[parameter_set_index]
+ # run over all function arguments
+ for parameter_index in parameter_set.size():
+ var parameter :Variant = parameter_set[parameter_index]
+ var arg_descriptor: GdFunctionArgument = arg_descriptors[parameter_index]
+ if parameter is Array:
+ var as_array: Array = parameter
+ # we need to convert the untyped array to the expected typed version
+ if arg_descriptor.is_typed_array():
+ parameter_set[parameter_index] = Array(as_array, arg_descriptor.type_hint(), "", null)
+ return parameter_sets
+
+
+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, "<empty>");
+ continue
+ dest.set(property_name, property_value)
diff --git a/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid
new file mode 100644
index 0000000..2d86843
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid
@@ -0,0 +1 @@
+uid://ck3w7dlo43ky6
diff --git a/addons/gdUnit4/src/core/parse/GdScriptParser.gd b/addons/gdUnit4/src/core/parse/GdScriptParser.gd
new file mode 100644
index 0000000..a102508
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdScriptParser.gd
@@ -0,0 +1,764 @@
+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_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 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_COMMENT,
+ 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
+
+
+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 = input.find("'", 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 = input.find('"', 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
+ # `<value>,` or `<function>())`
+ 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)
+ # enrich missing default values
+ fd.enrich_arguments(func_arguments)
+ fd.enrich_file_info(script_to_scan.resource_path, rowIndex + 1)
+ 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 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..11d95e1
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid
@@ -0,0 +1 @@
+uid://djbak301ao2c0
diff --git a/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd
new file mode 100644
index 0000000..9faf830
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd
@@ -0,0 +1,74 @@
+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\\((?<args>.*)\\)")
+
+
+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()
+ if runner.has_method("queue_free"):
+ (runner as Node).queue_free()
+ @warning_ignore("unsafe_method_access")
+ return runner.__run_expression()
+
+
+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..54da916
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid
@@ -0,0 +1 @@
+uid://cstpl6o675qkp
diff --git a/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd
new file mode 100644
index 0000000..527661d
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd
@@ -0,0 +1,163 @@
+## @deprecated see GdFunctionParameterSetResolver
+class_name GdUnitTestParameterSetResolver
+extends RefCounted
+
+const CLASS_TEMPLATE = """
+class_name _ParameterExtractor extends '${clazz_path}'
+
+func __extract_test_parameters() -> Array:
+ return ${test_params}
+
+"""
+
+const EXCLUDE_PROPERTIES_TO_COPY = [
+ "script",
+ "type",
+ "Node",
+ "_import_path"]
+
+
+var _fd: GdFunctionDescriptor
+var _static_sets_by_index := {}
+var _is_static := true
+
+func _init(fd: GdFunctionDescriptor) -> void:
+ _fd = fd
+
+
+func is_parameterized() -> bool:
+ return _fd.is_parameterized()
+
+
+func is_parameter_sets_static() -> bool:
+ return _is_static
+
+
+func is_parameter_set_static(index: int) -> bool:
+ return _is_static and _static_sets_by_index.get(index, false)
+
+
+# validates the given arguments are complete and matches to required input fields of the test function
+func validate(parameter_sets: Array, parameter_set_index: int) -> GdUnitResult:
+ if parameter_sets.size() < parameter_set_index:
+ return GdUnitResult.error("Internal error: the resolved paremeterset has invalid size.")
+
+ var input_values: Array = parameter_sets[parameter_set_index]
+ if input_values == null:
+ return GdUnitResult.error("The parameter set '%s' must be an Array!" % parameter_sets[parameter_set_index])
+
+ # check given parameter set with test case arguments
+ var input_arguments := _fd.args()
+ var expected_arg_count := input_arguments.size() - 1 #(-1 we exclude the parameter set itself)
+ var current_arg_count := input_values.size()
+ if current_arg_count != expected_arg_count:
+ var arg_names := input_arguments\
+ .filter(func(arg: GdFunctionArgument) -> bool: return not arg.is_parameter_set())\
+ .map(func(arg: GdFunctionArgument) -> String: return str(arg))
+
+ return GdUnitResult.error("""
+ The test data set at index (%d) does not match the expected test arguments:
+ test function: [color=snow]func test...(%s)[/color]
+ test input values: [color=snow]%s[/color]
+ """
+ .dedent() % [parameter_set_index, ",".join(arg_names), input_values])
+ return GdUnitTestParameterSetResolver.validate_parameter_types(input_arguments, input_values)
+
+
+static func validate_parameter_types(input_arguments: Array[GdFunctionArgument], input_values: Array) -> GdUnitResult:
+ for i in input_arguments.size():
+ var input_param: GdFunctionArgument = input_arguments[i]
+ # only check the test input arguments
+ if input_param.is_parameter_set():
+ continue
+ var input_param_type := input_param.type()
+ var input_value :Variant = input_values[i]
+ var input_value_type := typeof(input_value)
+ # input parameter is not typed or is Variant we skip the type test
+ if input_param_type == TYPE_NIL or input_param_type == GdObjects.TYPE_VARIANT:
+ continue
+ # is input type enum allow int values
+ if input_param_type == GdObjects.TYPE_VARIANT and input_value_type == TYPE_INT:
+ continue
+ # allow only equal types and object == null
+ if input_param_type == TYPE_OBJECT and input_value_type == TYPE_NIL:
+ continue
+ if input_param_type != input_value_type:
+ return GdUnitResult.error("""
+ The test data value does not match the expected input type!
+ input value: [color=snow]'%s', <%s>[/color]
+ expected argument: [color=snow]%s[/color]
+ """
+ .dedent() % [input_value, type_string(input_value_type), str(input_param)])
+ return GdUnitResult.success("No errors found.")
+
+
+func _extract_property_names(node :Node) -> PackedStringArray:
+ return node.get_property_list()\
+ .map(func(property :Dictionary) -> String: return property["name"])\
+ .filter(func(property :String) -> bool: return !EXCLUDE_PROPERTIES_TO_COPY.has(property))
+
+
+# tests if the test property set contains an property reference by name, if not the parameter set holds only static values
+func _is_static_parameter_set(parameters :String, property_names :PackedStringArray) -> bool:
+ for property_name in property_names:
+ if parameters.contains(property_name):
+ _is_static = false
+ return false
+ return true
+
+
+# extracts the arguments from the given test case, using kind of reflection solution
+# to restore the parameters from a string representation to real instance type
+func load_parameter_sets(test_suite: Node) -> GdUnitResult:
+ var source_script: Script = test_suite.get_script()
+ var parameter_arg := GdFunctionArgument.get_parameter_set(_fd.args())
+ var source_code := CLASS_TEMPLATE \
+ .replace("${clazz_path}", source_script.resource_path) \
+ .replace("${test_params}", parameter_arg.value_as_string())
+ var script := GDScript.new()
+ script.source_code = source_code
+ # enable this lines only for debuging
+ #script.resource_path = GdUnitFileAccess.create_temp_dir("parameter_extract") + "/%s__.gd" % test_case.get_name()
+ #DirAccess.remove_absolute(script.resource_path)
+ #ResourceSaver.save(script, script.resource_path)
+ var result := script.reload()
+ if result != OK:
+ return GdUnitResult.error("Extracting test parameters failed! Script loading error: %s" % error_string(result))
+ var instance :Object = script.new()
+ GdUnitTestParameterSetResolver.copy_properties(test_suite, instance)
+ (instance as Node).queue_free()
+ var parameter_sets: Array = instance.call("__extract_test_parameters")
+ fixure_typed_parameters(parameter_sets, _fd.args())
+ return GdUnitResult.success(parameter_sets)
+
+
+func fixure_typed_parameters(parameter_sets: Array, arg_descriptors: Array[GdFunctionArgument]) -> Array:
+ for parameter_set_index in parameter_sets.size():
+ var parameter_set: Array = parameter_sets[parameter_set_index]
+ # run over all function arguments
+ for parameter_index in parameter_set.size():
+ var parameter :Variant = parameter_set[parameter_index]
+ var arg_descriptor: GdFunctionArgument = arg_descriptors[parameter_index]
+ if parameter is Array:
+ var as_array: Array = parameter
+ # we need to convert the untyped array to the expected typed version
+ if arg_descriptor.is_typed_array():
+ parameter_set[parameter_index] = Array(as_array, arg_descriptor.type_hint(), "", null)
+ return parameter_sets
+
+
+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, "<empty>");
+ continue
+ dest.set(property_name, property_value)
diff --git a/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid
new file mode 100644
index 0000000..a12fb18
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid
@@ -0,0 +1 @@
+uid://deasps2102or7
diff --git a/addons/gdUnit4/src/core/report/GdUnitReport.gd b/addons/gdUnit4/src/core/report/GdUnitReport.gd
new file mode 100644
index 0000000..aaf309f
--- /dev/null
+++ b/addons/gdUnit4/src/core/report/GdUnitReport.gd
@@ -0,0 +1,84 @@
+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
+
+
+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 _to_string() -> String:
+ if _line_number == -1:
+ return "[color=green]line [/color][color=aqua]<n/a>:[/color] %s" % [_message]
+ return "[color=green]line [/color][color=aqua]%d:[/color] %s" % [_line_number, _message]
+
+
+func serialize() -> Dictionary:
+ return {
+ "type" :_type,
+ "line_number" :_line_number,
+ "message" :_message
+ }
+
+
+func deserialize(serialized :Dictionary) -> GdUnitReport:
+ _type = serialized["type"]
+ _line_number = serialized["line_number"]
+ _message = serialized["message"]
+ 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..934a280
--- /dev/null
+++ b/addons/gdUnit4/src/core/report/GdUnitReport.gd.uid
@@ -0,0 +1 @@
+uid://c27ddgo83osir
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd
new file mode 100644
index 0000000..34dcfa3
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd
@@ -0,0 +1,470 @@
+#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 <directory>
+##
+## # Run specific test suite with ignored tests
+## runtest -a <directory> -i <testsuite:test_name>
+## [/codeblock]
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+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 <directory|path of testsuite>",
+ "Adds the given test suite or directory to the execution pipeline.",
+ TYPE_STRING
+ ),
+ CmdOption.new(
+ "-i, --ignore",
+ "-i <testsuite_name|testsuite_name:test-name>",
+ "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 <directory>",
+ "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 <count>",
+ "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 <suite name>", "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)
+ if what == NOTIFICATION_PREDELETE:
+ prints("Finallize .. done")
+
+
+func init_runner() -> void:
+ 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:
+ _state = EXIT
+ GdUnitTools.dispose_all()
+ await GdUnitMemoryObserver.gc_on_guarded_instances()
+ await super(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 <directory|path of testsuite>
+ runtest -a <directory> -i <path of testsuite|testsuite_name|testsuite_name:test_name>
+ """.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
+
+ _test_cases = discover_tests()
+ 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)
+ 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..58e1061
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid
@@ -0,0 +1 @@
+uid://dllu6e8jp1nuk
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd
new file mode 100644
index 0000000..0fd8fc7
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd
@@ -0,0 +1,90 @@
+@tool
+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
+
+
+## Cleanup and quit the runner.[br]
+## [br]
+## [param code] The exit code to return.
+func quit(code: int) -> void:
+ if code != RETURN_SUCCESS:
+ _state = EXIT
+ await GdUnitMemoryObserver.gc_on_guarded_instances()
+ await super.quit(code)
+
+
+## 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..fd35f6b
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid
@@ -0,0 +1 @@
+uid://cc77n13jrf5x6
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..aa729ff
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid
@@ -0,0 +1 @@
+uid://dj37sbm0qh3sj
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd
new file mode 100644
index 0000000..6551e08
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd
@@ -0,0 +1,180 @@
+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.
+
+## 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_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
+
+## Runner state machine
+enum {
+ READY,
+ INIT,
+ RUN,
+ STOP,
+ EXIT
+}
+
+func _init() -> void:
+ if OS.get_cmdline_args().size() == 1:
+ 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 get_tree().process_frame
+ await get_tree().physics_frame
+ get_tree().quit(code)
+
+
+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..cfd20db
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid
@@ -0,0 +1 @@
+uid://wnlvwy04lmn6
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..af9b576
--- /dev/null
+++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid
@@ -0,0 +1 @@
+uid://d36rdpq1g8nt1
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..e901a3d
--- /dev/null
+++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid
@@ -0,0 +1 @@
+uid://b2v71bhkym4ji
diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd
new file mode 100644
index 0000000..402199b
--- /dev/null
+++ b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd
@@ -0,0 +1,70 @@
+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:
+ _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..01dc8f6
--- /dev/null
+++ b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid
@@ -0,0 +1 @@
+uid://c8t88hv3p8rvu
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
+
+## { <thread_id> = <GdUnitThreadContext> }
+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..b943f92
--- /dev/null
+++ b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid
@@ -0,0 +1 @@
+uid://7j6xocp0kaix
diff --git a/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd
new file mode 100644
index 0000000..cc93ee2
--- /dev/null
+++ b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd
@@ -0,0 +1,227 @@
+@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
+
+
+## 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..e87dda7
--- /dev/null
+++ b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid
@@ -0,0 +1 @@
+uid://bch3bjbfhr5lc
diff --git a/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd
new file mode 100644
index 0000000..5244efa
--- /dev/null
+++ b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd
@@ -0,0 +1,214 @@
+@tool
+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) -> void:
+ _print_message(message, _current_color, _current_indent, _current_flags)
+ reset()
+
+
+## Prints a message with current formatting settings followed by a newline.[br]
+## [br]
+## [param message] The text to print.
+func println_message(message: String) -> void:
+ _println_message(message, _current_color, _current_indent, _current_flags)
+ reset()
+
+
+## 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()
+
+
+## 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.
+func _print_message(_message: String, _color: Color, _indent: int, _flags: int) -> void:
+ pass
+
+
+## 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.
+func _println_message(_message: String, _color: Color, _indent: int, _flags: int) -> void:
+ pass
+
+
+## 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.
+func _print_at(_message: String, _cursor_pos: int, _color: Color, _effect: GdUnitMessageWriter.Effect, _align: Align, _flags: int) -> void:
+ pass
+
+
+## Clears all output content.[br]
+## [br]
+## To be overridden by concrete formatters.
+func clear() -> void:
+ pass
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..0fe00d2
--- /dev/null
+++ b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid
@@ -0,0 +1 @@
+uid://5etcu5juhye8
diff --git a/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd
new file mode 100644
index 0000000..b9f79e4
--- /dev/null
+++ b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd
@@ -0,0 +1,115 @@
+@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
+
+## 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
+
+
+## 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
+
+
+## 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:
+ for i in _indent:
+ _output.push_indent(1)
+ _output.push_color(_color)
+ message = _apply_flags(message, flags)
+ _output.append_text(message)
+ _output.pop()
+ for i in _indent:
+ _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
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..23fe6c0
--- /dev/null
+++ b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid
@@ -0,0 +1 @@
+uid://cn0dawecdtlqx
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;
+
+/// <summary>
+/// The GdUnit4 GDScript - C# API wrapper.
+/// </summary>
+public partial class GdUnit4CSharpApi : RefCounted
+{
+ /// <summary>
+ /// The signal to be emitted when the execution is completed.
+ /// </summary>
+ [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
+
+ /// <summary>
+ /// Indicates if the API loaded.
+ /// </summary>
+ /// <returns>Returns true if the API already loaded.</returns>
+ public static bool IsApiLoaded()
+ => true;
+
+ /// <summary>
+ /// Runs test discovery on the given script.
+ /// </summary>
+ /// <param name="sourceScript">The script to be scanned.</param>
+ /// <returns>The list of tests discovered as dictionary.</returns>
+ public static Array<Dictionary> 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<Dictionary>(),
+ (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<Dictionary>();
+#pragma warning restore IDE0028 // Do not catch general exception types
+ }
+ }
+
+ /// <summary>
+ /// Creates a test suite based on the specified source path and line number.
+ /// </summary>
+ /// <param name="sourcePath">The path to the source file from which to create the test suite.</param>
+ /// <param name="lineNumber">The line number in the source file where the method to test is defined.</param>
+ /// <param name="testSuitePath">The path where the test suite should be created.</param>
+ /// <returns>A dictionary containing information about the created test suite.</returns>
+ public static Dictionary CreateTestSuite(string sourcePath, int lineNumber, string testSuitePath)
+ => GdUnit4NetApiGodotBridge.CreateTestSuite(sourcePath, lineNumber, testSuitePath);
+
+ /// <summary>
+ /// Gets the version of the GdUnit4 assembly.
+ /// </summary>
+ /// <returns>The version string of the GdUnit4 assembly.</returns>
+ public static string Version()
+ => GdUnit4NetApiGodotBridge.Version();
+
+ /// <inheritdoc />
+ public override void _Notification(int what)
+ {
+ if (what != NotificationPredelete)
+ return;
+ executionCts?.Dispose();
+ executionCts = null;
+ }
+
+ /// <summary>
+ /// Executes the tests and using the listener for reporting the results.
+ /// </summary>
+ /// <param name="tests">A list of tests to be executed.</param>
+ /// <param name="listener">The listener to report the results.</param>
+ public void ExecuteAsync(Array<Dictionary> 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<TestSuiteNode> { 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));
+ }
+ }
+
+ /// <summary>
+ /// Will cancel the current test execution.
+ /// </summary>
+ 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<Dictionary> 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<Dictionary> DiscoverTests(CSharpScript sourceScript) => new();
+
+ public void ExecuteAsync(Array<Dictionary> 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/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..bca7e38
--- /dev/null
+++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid
@@ -0,0 +1 @@
+uid://b8y4p27r37pk1
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 <cb> 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..048f196
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/CallableDoubler.gd.uid
@@ -0,0 +1 @@
+uid://dhp64cdhym7sd
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..ebaa0a1
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd.uid
@@ -0,0 +1 @@
+uid://bydl05g1nfonb
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<class> 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..05ce336
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd.uid
@@ -0,0 +1 @@
+uid://b3nwjwttqye8a
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..ca2ad79
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd.uid
@@ -0,0 +1 @@
+uid://bx657fdodkwc6
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..4117f4e
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd.uid
@@ -0,0 +1 @@
+uid://teng0fasbhen
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..54ed63f
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd.uid
@@ -0,0 +1 @@
+uid://cf4wbrdcfcsap
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..9b42fe6
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd.uid
@@ -0,0 +1 @@
+uid://djphb0prfq8if
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..322f6c9
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd.uid
@@ -0,0 +1 @@
+uid://3rjtjcj64vpt
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..9c4c194
--- /dev/null
+++ b/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd.uid
@@ -0,0 +1 @@
+uid://g7yhter46jh
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..f2a9436
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd.uid
@@ -0,0 +1 @@
+uid://c0b4yc7wahhsc
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..3c67b8a
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd.uid
@@ -0,0 +1 @@
+uid://droy0thepcy
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..ca5a34d
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/Fuzzer.gd.uid
@@ -0,0 +1 @@
+uid://dnqosea1sx5f4
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..e5f1d95
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/IntFuzzer.gd.uid
@@ -0,0 +1 @@
+uid://bt16fv8esh0f8
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..3fa5555
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/StringFuzzer.gd.uid
@@ -0,0 +1 @@
+uid://b8l1qcoler3pb
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..c68746c
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd.uid
@@ -0,0 +1 @@
+uid://dvpctakk4c5u0
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..cba3a8f
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd.uid
@@ -0,0 +1 @@
+uid://cde6yudknpbgf
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..03ccbea
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd.uid
@@ -0,0 +1 @@
+uid://n0fqyyrngu3p
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..68f1c4f
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd.uid
@@ -0,0 +1 @@
+uid://cjg3dte8ifp72
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..e6d4402
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd.uid
@@ -0,0 +1 @@
+uid://b331ucr86yg1o
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..364cd27
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd.uid
@@ -0,0 +1 @@
+uid://b2tgtmtmgsl44
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..916a23c
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd.uid
@@ -0,0 +1 @@
+uid://diq5b7mgjyplv
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..b061e03
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd.uid
@@ -0,0 +1 @@
+uid://dj5ysoawmt5ht
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..c17d859
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd.uid
@@ -0,0 +1 @@
+uid://ct81jyk5s7osa
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..8470002
--- /dev/null
+++ b/addons/gdUnit4/src/mocking/GdUnitMock.gd.uid
@@ -0,0 +1 @@
+uid://cknx4b72mqfq6
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..8380b27
--- /dev/null
+++ b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid
@@ -0,0 +1 @@
+uid://bx8ykipeol8m1
diff --git a/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd
new file mode 100644
index 0000000..774ca3e
--- /dev/null
+++ b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd
@@ -0,0 +1,143 @@
+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:
+ return Engine.get_meta(__INSTANCE_ID).__verifier_instance
+
+
+static func __is_prepare_return_value() -> bool:
+ return __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()
+ 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..fbe29d3
--- /dev/null
+++ b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid
@@ -0,0 +1 @@
+uid://bsuaeeb5m6lyd
diff --git a/addons/gdUnit4/src/monitor/ErrorLogEntry.gd b/addons/gdUnit4/src/monitor/ErrorLogEntry.gd
new file mode 100644
index 0000000..ebbc93f
--- /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 _details: String
+
+
+func _init(type: TYPE, line: int, message: String, details: String) -> void:
+ _type = type
+ _line = line
+ _message = message
+ _details = details
+
+
+func _to_string() -> String:
+ return _message
+
+
+static func of_push_warning(line: int, message: String, stack_trace: PackedStringArray) -> ErrorLogEntry:
+ return ErrorLogEntry.new(TYPE.PUSH_WARNING, line, message, "\n".join(stack_trace))
+
+
+static func of_push_error(line: int, message: String, stack_trace: PackedStringArray) -> ErrorLogEntry:
+ return ErrorLogEntry.new(TYPE.PUSH_ERROR, line, message, "\n".join(stack_trace))
+
+
+static func of_script_error(line: int, message: String, stack_trace: PackedStringArray) -> ErrorLogEntry:
+ return ErrorLogEntry.new(TYPE.SCRIPT_ERROR, line, message, "\n".join(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..4025709
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/ErrorLogEntry.gd.uid
@@ -0,0 +1 @@
+uid://ca534u63pypt7
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..b4875fd
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GdUnitMonitor.gd.uid
@@ -0,0 +1 @@
+uid://bd5amxrxcckjv
diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd b/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd
new file mode 100644
index 0000000..c195760
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd
@@ -0,0 +1,70 @@
+class_name GdUnitOrphanNodeInfo
+extends RefCounted
+
+enum GdUnitOrphanType {
+ member,
+ variable,
+ unknown
+}
+
+
+var _id: int
+var _orphan_type: GdUnitOrphanType
+var _type: String
+var _name: String
+var _script_ref: String
+var _func_ref: String
+var _next: GdUnitOrphanNodeInfo
+
+const text_color := Color.ANTIQUE_WHITE
+const function_color := Color.SKY_BLUE
+const member_variable_color := Color.SALMON
+const engine_type_color := Color.LIGHT_GREEN
+const script_path_color := Color.CORNFLOWER_BLUE
+
+
+func _init(orphan_type: GdUnitOrphanType, id: int, type: String, name: String, script_ref: String, func_ref: String = "") -> void:
+ _orphan_type = orphan_type
+ _id = id
+ _type = type
+ _name = name
+ _script_ref = script_ref
+ _func_ref = func_ref
+
+
+func as_trace(info: GdUnitOrphanNodeInfo, show_orphan_id := true) -> String:
+ var trace := ""
+ if show_orphan_id:
+ trace += "• <%s> Id:%s\n" % [
+ _colored(info._type, engine_type_color),
+ _colored(info._id, engine_type_color)]
+ match info._orphan_type:
+ GdUnitOrphanType.member:
+ return trace + " at %s script: %s" % [
+ _colored(info._name, member_variable_color),
+ _colored(info._script_ref, script_path_color)
+ ] + sub_info(info._next)
+ GdUnitOrphanType.variable:
+ return trace + " at %s script: %s.%s()" % [
+ _colored(info._name, member_variable_color),
+ _colored(info._script_ref, script_path_color),
+ _colored(info._func_ref, function_color),
+ ]
+ GdUnitOrphanType.unknown:
+ return trace + " %s" % [
+ _colored(info._name, member_variable_color)
+ ]
+
+ _:
+ return trace + " No details available"
+
+
+func sub_info(next: GdUnitOrphanNodeInfo) -> String:
+ if next == null:
+ return ""
+
+ return "\n" + as_trace(next, false)
+
+
+static func _colored(value: Variant, color: Color) -> String:
+ return "[color=%s]%s[/color]" % [color.to_html(), value]
diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd.uid b/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd.uid
new file mode 100644
index 0000000..a74b4e2
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd.uid
@@ -0,0 +1 @@
+uid://lc78iyt2b13e
diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd
new file mode 100644
index 0000000..05f11cd
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd
@@ -0,0 +1,221 @@
+class_name GdUnitOrphanNodesMonitor
+extends GdUnitMonitor
+
+const excluded_frame_files: PackedStringArray = [
+ "GdUnitOrphanNodesMonitor",
+ "GdUnitExecutionContext",
+ "_TestCase",
+ "IGdUnitExecutionStage",
+ "GdUnitTestCaseSingleTestStage",
+ "GdUnitTestCaseSingleExecutionStage",
+ "GdUnitTestCaseExecutionStage",
+ "GdUnitTestSuiteExecutionStage",
+ "GdUnitTestSuiteExecutor"
+]
+
+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
+ for orphan_id in _get_orphan_node_ids():
+ var orphan_to_find := instance_from_id(orphan_id)
+ _collect_orphan_info(orphan_to_find)
+
+
+func _collect_orphan_info(orphan_to_find: Object) -> void:
+ if orphan_to_find == null:
+ return
+
+ var orphan_node := _find_orphan_on_backtraces(orphan_to_find)
+ if orphan_node:
+ _collected_orphan_infos.append(orphan_node)
+ return
+
+ if Engine.has_meta("GdUnitSceneRunner"):
+ var current_scene_runner:GdUnitSceneRunner = Engine.get_meta("GdUnitSceneRunner")
+ if is_instance_valid(current_scene_runner):
+ orphan_node = _find_orphan_at_node(orphan_to_find, current_scene_runner.scene())
+ if orphan_node:
+ _collected_orphan_infos.append(orphan_node)
+ return
+
+ # not able to find the orphan node via backtrace loaded nodeds
+ var message := "No details found. Verify called functions manually."
+ if not EngineDebugger.is_active():
+ message = "No details available. [color=yellow]Run tests in debug mode to collect details.[/color]"
+
+ _collected_orphan_infos.append(GdUnitOrphanNodeInfo.new(
+ GdUnitOrphanNodeInfo.GdUnitOrphanType.unknown,
+ orphan_to_find.get_instance_id(),
+ orphan_to_find.get_class(),
+ message,
+ ""))
+
+
+func _find_orphan_at_node(orphan_to_find: 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():
+ var property_name: String = property["name"]
+ var property_type: int = property["type"]
+ # Is untyped or type object
+ if property_type in [TYPE_NIL, TYPE_OBJECT]:
+ var property_instance: Variant = node.get(property_name)
+ @warning_ignore("unsafe_cast")
+ var property_as_node := property_instance as Node if property_instance != null else null
+ if property_as_node == null:
+ continue
+ if property_as_node == orphan_to_find:
+ return GdUnitOrphanNodeInfo.new(
+ GdUnitOrphanNodeInfo.GdUnitOrphanType.member,
+ orphan_to_find.get_instance_id(),
+ orphan_to_find.get_class(),
+ property_name,
+ script.resource_path)
+
+ # Search on node childs
+ var orphan_node_info := _find_orphan_at_node(orphan_to_find, property_as_node)
+ if orphan_node_info:
+ orphan_node_info._next = GdUnitOrphanNodeInfo.new(
+ GdUnitOrphanNodeInfo.GdUnitOrphanType.member,
+ orphan_to_find.get_instance_id(),
+ orphan_to_find.get_class(),
+ property_name,
+ script.resource_path)
+ return orphan_node_info
+
+ # Second over all children
+ for child_node in node.get_children():
+ var orphan_node_info := _find_orphan_at_node(orphan_to_find, child_node)
+ if orphan_node_info:
+ return orphan_node_info
+ return null
+
+
+func _is_frame_file_excluded(frame_file: String) -> bool:
+ for file in excluded_frame_files:
+ if frame_file.contains(file):
+ return true
+ return false
+
+
+func _find_orphan_on_backtraces(orphan_to_find: Object) -> GdUnitOrphanNodeInfo:
+ for script_backtrace in Engine.capture_script_backtraces(true):
+ for frame in script_backtrace.get_frame_count():
+ var frame_file := script_backtrace.get_frame_file(frame)
+ if _is_frame_file_excluded(frame_file):
+ continue
+
+ # Scan function variables
+ for l_index in script_backtrace.get_local_variable_count(frame):
+ var variable_instance: Variant = script_backtrace.get_local_variable_value(frame, l_index)
+ var variable_name := script_backtrace.get_local_variable_name(frame, l_index)
+ if typeof(variable_instance) in [TYPE_NIL, TYPE_OBJECT]:
+ @warning_ignore("unsafe_cast")
+ var node := variable_instance as Node
+ if node == null:
+ continue
+ if variable_instance == orphan_to_find:
+ return GdUnitOrphanNodeInfo.new(
+ GdUnitOrphanNodeInfo.GdUnitOrphanType.variable,
+ orphan_to_find.get_instance_id(),
+ orphan_to_find.get_class(),
+ variable_name,
+ script_backtrace.get_frame_file(frame),
+ script_backtrace.get_frame_function(frame))
+ else:
+ var orphan_node_info := _find_orphan_at_node(orphan_to_find, node)
+ if orphan_node_info:
+ return orphan_node_info
+
+ # Scan class members
+ for m_index in script_backtrace.get_member_variable_count(frame):
+ var member_instance: Variant = script_backtrace.get_member_variable_value(frame, m_index)
+ var member_name := script_backtrace.get_member_variable_name(frame, m_index)
+ if typeof(member_instance) in [TYPE_NIL, TYPE_OBJECT]:
+ @warning_ignore("unsafe_cast")
+ var node := member_instance as Node
+ if node == null:
+ continue
+ if member_instance == orphan_to_find:
+ return GdUnitOrphanNodeInfo.new(
+ GdUnitOrphanNodeInfo.GdUnitOrphanType.member,
+ orphan_to_find.get_instance_id(),
+ orphan_to_find.get_class(),
+ member_name,
+ script_backtrace.get_frame_file(frame))
+ else:
+ var orphan_node_info := _find_orphan_at_node(orphan_to_find, node)
+ if orphan_node_info:
+ return orphan_node_info
+ return null
+
+
+static func _get_orphan_node_ids() -> Array[int]:
+ @warning_ignore("unsafe_property_access", "unsafe_method_access")
+ return Engine.get_main_loop().root.get_orphan_node_ids()
diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid
new file mode 100644
index 0000000..42c79c7
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid
@@ -0,0 +1 @@
+uid://ckqlijgdcnfno
diff --git a/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd
new file mode 100644
index 0000000..b1b28d7
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd
@@ -0,0 +1,122 @@
+class_name GodotGdErrorMonitor
+extends GdUnitMonitor
+
+
+var _logger: GdUnitLogger
+
+
+class GdUnitLogger extends Logger:
+ var _entries: Array[ErrorLogEntry] = []
+ var _line_number: int
+ 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:
+ match error_type:
+ ErrorType.ERROR_TYPE_WARNING:
+ if _is_report_push_errors:
+ var stack_trace := _build_stack_trace(script_backtraces)
+ _entries.append(ErrorLogEntry.of_push_warning(_line_number, message, stack_trace))
+
+ ErrorType.ERROR_TYPE_ERROR:
+ if _is_report_push_errors:
+ var stack_trace := _build_stack_trace(script_backtraces)
+ _entries.append(ErrorLogEntry.of_push_error(_line_number, message, stack_trace))
+
+ ErrorType.ERROR_TYPE_SCRIPT:
+ if _is_report_script_errors:
+ var stack_trace := _build_stack_trace(script_backtraces)
+ _entries.append(ErrorLogEntry.of_script_error(_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 _build_stack_trace(script_backtraces: Array[ScriptBacktrace]) -> PackedStringArray:
+ for sb in script_backtraces:
+ for frame in sb.get_frame_count():
+ # Find start of test stack
+ if sb.get_frame_file(frame) == "res://addons/gdUnit4/src/core/_TestCase.gd":
+ var stack_trace := PackedStringArray()
+ for test_case_frame in range(0, frame):
+ _line_number = sb.get_frame_line(test_case_frame)
+ stack_trace.append(" at %s:%s" % [sb.get_frame_file(test_case_frame), sb.get_frame_line(test_case_frame)])
+ return stack_trace
+ # if no stack trace collected, we in an await function call
+ var sb := script_backtraces[0]
+ return [" at %s:%s" % [sb.get_frame_file(0), sb.get_frame_line(0)]]
+
+
+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 %s
+ %s""".dedent().trim_prefix("\n") % [
+ GdAssertMessages._error("Godot Runtime Error !"),
+ GdAssertMessages._error("Error:"),
+ GdAssertMessages._colored_value(errorLog._message),
+ GdAssertMessages._colored(errorLog._details, GdAssertMessages.VALUE_COLOR)]
+ return GdUnitReport.new().create(GdUnitReport.ABORT, errorLog._line, failure)
+
+
+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..1b1e96b
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd.uid
@@ -0,0 +1 @@
+uid://bhessnp2uk5no
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..b44864e
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitServer.gd.uid
@@ -0,0 +1 @@
+uid://cu75icxofcpxv
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 = "<<JRD>>"
diff --git a/addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid b/addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid
new file mode 100644
index 0000000..b847741
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid
@@ -0,0 +1 @@
+uid://cg7ssosnfdrcn
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..99a1242
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTask.gd.uid
@@ -0,0 +1 @@
+uid://dapl4oygj1lal
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..7166e61
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTcpClient.gd.uid
@@ -0,0 +1 @@
+uid://bitvc4op4ulwh
diff --git a/addons/gdUnit4/src/network/GdUnitTcpNode.gd b/addons/gdUnit4/src/network/GdUnitTcpNode.gd
new file mode 100644
index 0000000..156c764
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTcpNode.gd
@@ -0,0 +1,73 @@
+class_name GdUnitTcpNode
+extends Node
+
+
+func rpc_send(stream: StreamPeerTCP, data: RPC) -> void:
+ var package_buffer := StreamPeerBuffer.new()
+ var buffer := data.serialize().to_utf16_buffer()
+ package_buffer.put_u32(0xDEADBEEF)
+ package_buffer.put_u32(buffer.size())
+ var status_code := package_buffer.put_data(buffer)
+ if status_code != OK:
+ push_error("'rpc_send:' Can't put_data(), error: %s" % error_string(status_code))
+ return
+ stream.put_data(package_buffer.data_array)
+
+
+func receive_packages(stream: StreamPeerTCP, rpc_cb: Callable = noop) -> Array[RPC]:
+ var received_packages: Array[RPC] = []
+ var package_buffer := StreamPeerBuffer.new()
+ if stream.get_status() != StreamPeerTCP.STATUS_CONNECTED:
+ return received_packages
+
+ while stream.get_status() == StreamPeerTCP.STATUS_CONNECTED and stream.get_available_bytes() > 0:
+ var buffer := stream.get_data(8)
+ var status_code: int = buffer[0]
+ if status_code != OK:
+ push_error("'receive_packages:' Can't get_data(%d) for available_bytes, error: %s"
+ % [stream.get_available_bytes(), error_string(status_code)])
+ return received_packages
+
+ var data_package: PackedByteArray
+ package_buffer.data_array = buffer[1]
+ package_buffer.seek(0)
+
+ if package_buffer.get_u32() == 0xDEADBEEF:
+ var size := package_buffer.get_u32()
+ if stream.get_status() != StreamPeerTCP.STATUS_CONNECTED:
+ return received_packages
+ if stream.get_available_bytes() < size:
+ prints("size check:",
+ package_buffer.get_size(), ":",
+ package_buffer.get_position(),
+ "to read:",
+ size,
+ "available size:",
+ stream.get_available_bytes())
+ push_error("'receive_packages:' Can't receive data get_data(%d) for package, error: %s" % [size, error_string(status_code)])
+ return received_packages
+
+ buffer = stream.get_data(size)
+ package_buffer.data_array = buffer[1]
+
+ var rpc_data := package_buffer.get_data(size)
+ status_code = rpc_data[0]
+ if status_code != OK:
+ push_error("'receive_packages:' Can't get_data(%d) for package, error: %s" % [size, error_string(status_code)])
+ continue
+ data_package = rpc_data[1]
+ else:
+ data_package = buffer[1]
+
+ var json := data_package.get_string_from_utf16()
+ if json.is_empty():
+ push_warning("json is empty, can't process data")
+ continue
+ var data := RPC.deserialize(json)
+ 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..4483fe2
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTcpNode.gd.uid
@@ -0,0 +1 @@
+uid://c8bwg30yoifsu
diff --git a/addons/gdUnit4/src/network/GdUnitTcpServer.gd b/addons/gdUnit4/src/network/GdUnitTcpServer.gd
new file mode 100644
index 0000000..997d6e3
--- /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..76f0fc0
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTcpServer.gd.uid
@@ -0,0 +1 @@
+uid://csm58erfvsxld
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..40c58dc
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPC.gd.uid
@@ -0,0 +1 @@
+uid://na86vw3ufqjo
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..016194f
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd.uid
@@ -0,0 +1 @@
+uid://e0qdfuxqc6y0
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..3114387
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd.uid
@@ -0,0 +1 @@
+uid://brg4jjgbo7okh
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..2543b41
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd.uid
@@ -0,0 +1 @@
+uid://co8lhd0kirsur
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..d470686
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPCMessage.gd.uid
@@ -0,0 +1 @@
+uid://muf7ucwnbkuk
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..6092039
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid
@@ -0,0 +1 @@
+uid://koedaj41qehf
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..c9be1dc
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid
@@ -0,0 +1 @@
+uid://dcm0384aabejh
diff --git a/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd
new file mode 100644
index 0000000..2801696
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd
@@ -0,0 +1,47 @@
+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():
+ report_message += _text_formatter.call(str(report)) + "\n"
+ 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..eaf88d0
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid
@@ -0,0 +1 @@
+uid://dnts7oirmp8ad
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..30ffd28
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid
@@ -0,0 +1 @@
+uid://5u0wypoy6una
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..2784de4
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid
@@ -0,0 +1 @@
+uid://dyii7lcvudbf8
diff --git a/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd
new file mode 100644
index 0000000..9978deb
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd
@@ -0,0 +1,235 @@
+@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
+var _text_color: Color = Color.ANTIQUE_WHITE
+var _function_color: Color = Color.ANTIQUE_WHITE
+var _engine_type_color: Color = Color.ANTIQUE_WHITE
+
+
+func _init(writer: GdUnitMessageWriter, detailed := false) -> void:
+ _writer = writer
+ _writer.clear()
+ _detailed = detailed
+ if _detailed:
+ _status_indent = 20
+ init_colors()
+
+
+func init_colors() -> void:
+ if Engine.is_editor_hint():
+ var settings := EditorInterface.get_editor_settings()
+ _text_color = settings.get_setting("text_editor/theme/highlighting/text_color")
+ _function_color = settings.get_setting("text_editor/theme/highlighting/function_color")
+ _engine_type_color = settings.get_setting("text_editor/theme/highlighting/engine_type_color")
+
+
+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(), _engine_type_color)
+
+ GdUnitEvent.TESTSUITE_AFTER:
+ if not event.reports().is_empty():
+ _writer.indent(1).color(_engine_type_color).print_message(event._suite_name)
+ print_message(" > ")
+ print_message("finalize()", _function_color)
+ _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)
+ _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(_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(_engine_type_color).print_message(suite_name)
+ print_message(" > ")
+ print_message(test.display_name, _function_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:
+ 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(Color.DARK_TURQUOISE) \
+ .style(GdUnitMessageWriter.BOLD | GdUnitMessageWriter.UNDERLINE) \
+ .println_message("Report:")
+ var text := str(report)
+ for line in text.split("\n", false):
+ _writer.indent(2).color(Color.DARK_TURQUOISE).println_message(line)
+
+ 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 = _text_color) -> void:
+ _writer.color(color).print_message(message)
+
+
+func println_message(message: String, color: Color = _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..7ca4b35
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid
@@ -0,0 +1 @@
+uid://d0bs18q20xbdb
diff --git a/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd
new file mode 100644
index 0000000..74b961b
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd
@@ -0,0 +1,60 @@
+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 template := GdUnitHtmlPatterns.load_template("res://addons/gdUnit4/src/reporters/html/template/folder_report.html")
+ var path_report := GdUnitHtmlPatterns.build(template, self, "")
+ path_report = apply_testsuite_reports(report_dir, path_report, _reports)
+
+ var output_path := "%s/path/%s.html" % [report_dir, path().replace("/", ".")]
+ 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(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 := GdUnitHtmlReportWriter.create_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..607e423
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid
@@ -0,0 +1 @@
+uid://ct0fnu7t8q2lw
diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd
new file mode 100644
index 0000000..b8be904
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd
@@ -0,0 +1,199 @@
+class_name GdUnitHtmlPatterns
+extends RefCounted
+
+const TABLE_RECORD_TESTSUITE = """
+ <tr class="${report_state}">
+ <td><a href=${report_link}>${testsuite_name}</a></td>
+ <td><span class="status status-${report_state}">${report_state_label}</span></td>
+ <td>${test_count}</td>
+ <td>${skipped_count}</td>
+ <td>${flaky_count}</td>
+ <td>${failure_count}</td>
+ <td>${orphan_count}</td>
+ <td>${duration}</td>
+ <td>
+ <div class="status-bar">
+ <div class="status-bar-column status-skipped" style="width: ${skipped-percent};"></div>
+ <div class="status-bar-column status-passed" style="width: ${passed-percent};"></div>
+ <div class="status-bar-column status-flaky" style="width: ${flaky-percent};"></div>
+ <div class="status-bar-column status-error" style="width: ${error-percent};"></div>
+ <div class="status-bar-column status-failed" style="width: ${failed-percent};"></div>
+ <div class="status-bar-column status-warning" style="width: ${warning-percent};"></div>
+ </div>
+ </td>
+ </tr>
+"""
+
+const TABLE_RECORD_PATH = """
+ <tr class="${report_state}">
+ <td><a class="${report_state}" href="${report_link}">${path}</a></td>
+ <td><span class="status status-${report_state}">${report_state_label}</span></td>
+ <td>${test_count}</td>
+ <td>${skipped_count}</td>
+ <td>${flaky_count}</td>
+ <td>${failure_count}</td>
+ <td>${orphan_count}</td>
+ <td>${duration}</td>
+ <td>
+ <div class="status-bar">
+ <div class="status-bar-column status-skipped" style="width: ${skipped-percent};"></div>
+ <div class="status-bar-column status-passed" style="width: ${passed-percent};"></div>
+ <div class="status-bar-column status-flaky" style="width: ${flaky-percent};"></div>
+ <div class="status-bar-column status-error" style="width: ${error-percent};"></div>
+ <div class="status-bar-column status-failed" style="width: ${failed-percent};"></div>
+ <div class="status-bar-column status-warning" style="width: ${warning-percent};"></div>
+ </div>
+ </td>
+ </tr>
+"""
+
+
+const TABLE_REPORT_TESTSUITE = """
+ <tr class="${report_state}">
+ <td>TestSuite hooks</td>
+ <td>n/a</td>
+ <td>${orphan_count}</td>
+ <td>${duration}</td>
+ <td class="report-column">
+ <pre>
+${failure-report}
+ </pre>
+ </td>
+ </tr>
+"""
+
+
+const TABLE_RECORD_TESTCASE = """
+ <tr class="testcase-group">
+ <td>${testcase_name}</td>
+ <td><span class="status status-${report_state}">${report_state_label}</span></td>
+ <td>${skipped_count}</td>
+ <td>${orphan_count}</td>
+ <td>${duration}</td>
+ <td class="report-column">
+ <pre>
+${failure-report}
+ </pre>
+ </td>
+ </tr>
+"""
+
+const CHARACTERS_TO_ENCODE := {
+ '<' : '&lt;',
+ '>' : '&gt;'
+}
+
+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) -> String:
+ return template\
+ .replace(PATH, get_report_path(report))\
+ .replace(BREADCRUMP_PATH_LINK, get_path_as_link(report))\
+ .replace(RESOURCE_PATH, report.get_resource_path())\
+ .replace(TESTSUITE_NAME, html_encoded(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 get_path_as_link(report: GdUnitReportSummary) -> String:
+ return "../path/%s.html" % report.path().replace("/", ".")
+
+
+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 html_encoded(value: String) -> String:
+ for key: String in CHARACTERS_TO_ENCODE.keys():
+ @warning_ignore("unsafe_cast")
+ value = value.replace(key, CHARACTERS_TO_ENCODE[key] as String)
+ return value
+
+
+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, 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, 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..aa548b9
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid
@@ -0,0 +1 @@
+uid://g7tdvdatyesm
diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd
new file mode 100644
index 0000000..a3d2bff
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd
@@ -0,0 +1,72 @@
+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, "")
+
+ var report_output_path := create_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))
+
+ var dir := report_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(report_output_path, FileAccess.WRITE).store_string(template)
+ return report_output_path
+
+
+static func create_output_path(report_dir :String, path: String, name: String) -> String:
+ return "%s/test_suites/%s.%s.html" % [report_dir, path.replace("/", "."), name]
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..8a9edd6
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid
@@ -0,0 +1 @@
+uid://co0bj8mioxbsk
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
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/template/.gdignore
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
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/template/css/logo.png
Binary files 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 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <meta http-equiv="x-ua-compatible" content="IE=edge" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>GdUnit4 Testsuite</title>
+ <link rel="stylesheet" href="../css/styles.css">
+ <link href="../css/breadcrumb.css" rel="stylesheet" type="text/css" />
+</head>
+
+<body>
+ <header>
+ <div class="logo">
+ <img src="../css/logo.png" alt="GdUnit4 Logo">
+ <span>GdUnit4</span>
+ </div>
+ <div class="report-container">
+ <h1>Report by Paths</h1>
+ <div>
+ <span class="label">${resource_path}</span>
+ </div>
+ <div class="summary">
+ <div class="summary-item">
+ <span class="label">TestSuites</span>
+ <span class="value">${suite_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Tests</span>
+ <span class="value">${test_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Skipped</span>
+ <span class="value">${skipped_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Flaky</span>
+ <span class="value">${flaky_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Failures</span>
+ <span class="value">${failure_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Orphans</span>
+ <span class="value">${orphan_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Duration</span>
+ <span class="value">${duration}</span>
+ </div>
+ <div class="success-rate">
+ <div class="check-icon status-${report_state}">✓</div>
+ <div class="rate-text">
+ <span class="label">Success Rate</span>
+ <span class="value">${success_percent}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="breadcrumb">
+ <a href="../index.html"><span>All</span></a>
+ <a href="${breadcrumb_path_link}"><span>${path}</span></a>
+ <a class="active" href="#"><span>${testsuite_name}</span></a>
+ </div>
+ </header>
+
+ <main>
+ <div>
+ <div class="tab-report">
+ <div class="grid-item tab">
+ <table id="report-table">
+ <thead>
+ <tr>
+ <th>TestSuites</th>
+ <th>Result</th>
+ <th>Tests</th>
+ <th>Skipped</th>
+ <th>Flaky</th>
+ <th>Failures</th>
+ <th>Orphans</th>
+ <th>Duration</th>
+ <th>Success rate</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${report_table_testsuites}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ </main>
+
+ <footer>
+ <p>Generated by <a href="https://github.com/MikeSchulze/gdUnit4">GdUnit4</a> at ${buid_date}</p>
+ <div class="status-legend">
+ <span class="status-legend-item">
+ <span class="status-box status-skipped"></span> Skipped
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-passed"></span> Passed
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-flaky"></span> Flaky
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-warning"></span> Warning
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-failed"></span> Failed
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-error"></span> Error
+ </span>
+ </div>
+ </footer>
+</body>
+
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>GdUnit4 Report</title>
+ <link rel="stylesheet" href="css/styles.css">
+</head>
+
+<body>
+ <header>
+ <div class="logo">
+ <img src="css/logo.png" alt="GdUnit4 Logo">
+ <span>GdUnit4</span>
+ </div>
+ <div class="report-container">
+ <h1>Summary Report</h1>
+ <div class="summary">
+ <div class="summary-item">
+ <span class="label">Test Suites</span>
+ <span class="value">${suite_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Tests</span>
+ <span class="value">${test_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Skipped</span>
+ <span class="value">${skipped_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Flaky</span>
+ <span class="value">${flaky_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Failures</span>
+ <span class="value">${failure_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Orphans</span>
+ <span class="value">${orphan_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Duration</span>
+ <span class="value">${duration}</span>
+ </div>
+ <div class="success-rate">
+ <div class="check-icon status-${report_state}">✓</div>
+ <div class="rate-text">
+ <span class="label">Success Rate</span>
+ <span class="value">${success_percent}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </header>
+ <main>
+ <nav>
+ <ul>
+ <li class="active" data-page="test-suites">TEST SUITES</li>
+ <li data-page="paths">PATHS</li>
+ <li data-page="logging">LOGGING</li>
+ </ul>
+ </nav>
+ <div id="content">
+ <!-- Content will be loaded here based on selected page -->
+ </div>
+ </main>
+
+ <footer>
+ <p>Generated by <a href="https://github.com/MikeSchulze/gdUnit4">GdUnit4</a> at ${buid_date}</p>
+ <div class="status-legend">
+ <span class="status-legend-item">
+ <span class="status-box status-skipped"></span> Skipped
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-passed"></span> Passed
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-flaky"></span> Flaky
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-warning"></span> Warning
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-failed"></span> Failed
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-error"></span> Error
+ </span>
+ </div>
+ </footer>
+
+ <script>
+ // Simple JavaScript to handle page switching
+ document.querySelectorAll('nav li').forEach(item => {
+ item.addEventListener('click', function () {
+ document.querySelectorAll('nav li').forEach(li => li.classList.remove('active'));
+ this.classList.add('active');
+ loadPage(this.getAttribute('data-page'));
+ });
+ });
+
+ function loadPage(page) {
+ if (page === 'test-suites') {
+ document.getElementById('content').innerHTML = `
+ <div class="grid-item tab">
+ <table>
+ <thead>
+ <tr>
+ <th>Test Suites</th>
+ <th>State</th>
+ <th>Tests</th>
+ <th>Skipped</th>
+ <th>Flaky</th>
+ <th>Failures</th>
+ <th>Orphans</th>
+ <th>Duration</th>
+ <th>Quick State</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${report_table_testsuites}
+ </tbody>
+ </table>
+ </div>`
+ } else if (page === 'paths') {
+ document.getElementById('content').innerHTML = `
+ <div class="grid-item tab">
+ <table>
+ <thead>
+ <tr>
+ <th>Paths</th>
+ <th>State</th>
+ <th>Tests</th>
+ <th>Skipped</th>
+ <th>Flaky</th>
+ <th>Failures</th>
+ <th>Orphans</th>
+ <th>Duration</th>
+ <th>Quick State</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${report_table_paths}
+ </tbody>
+ </table>
+ </div>`
+ } else if (page === 'logging') {
+ document.getElementById('content').innerHTML = `
+ <h3>${godot_log_file}</h3>
+ <div class="logging-container">
+ <iframe id="logging_content" src="${log_report}" frameborder="0" height="100%" width="100%"></iframe>
+ </div>`
+ }
+ }
+
+ // Load default page
+ loadPage('test-suites');
+ </script>
+</body>
+
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <meta http-equiv="x-ua-compatible" content="IE=edge" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>GdUnit4 Testsuite</title>
+ <link rel="stylesheet" href="../css/styles.css">
+ <link href="../css/breadcrumb.css" rel="stylesheet" type="text/css" />
+
+ <script src="https://code.jquery.com/jquery-3.6.4.js"></script>
+ <script>
+ $(document).ready(function () {
+ var report_view = $('#report_area').find('.report-column')
+
+ $('#report-table tr').click(function () {
+ var report = $(this).find('.report-column').html();
+ report_view.html(report)
+ $('.selected').removeClass('selected');
+ $(this).addClass('selected');
+ });
+ });
+ </script>
+</head>
+
+<body>
+ <header>
+ <div class="logo">
+ <img src="../css/logo.png" alt="GdUnit4 Logo">
+ <span>GdUnit4</span>
+ </div>
+ <div class="report-container">
+ <h1>Testsuite Report</h1>
+ <div>
+ <span class="label">${resource_path}</span>
+ </div>
+ <div class="summary">
+ <div class="summary-item">
+ <span class="label">Tests</span>
+ <span class="value">${test_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Skipped</span>
+ <span class="value">${skipped_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Flaky</span>
+ <span class="value">${flaky_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Failures</span>
+ <span class="value">${failure_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Orphans</span>
+ <span class="value">${orphan_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Duration</span>
+ <span class="value">${duration}</span>
+ </div>
+ <div class="success-rate">
+ <div class="check-icon status-${report_state}">✓</div>
+ <div class="rate-text">
+ <span class="label">Success Rate</span>
+ <span class="value">${success_percent}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="breadcrumb">
+ <a href="../index.html"><span>All</span></a>
+ <a href="${breadcrumb_path_link}"><span>${path}</span></a>
+ <a class="active" href="#"><span>${testsuite_name}</span></a>
+ </div>
+ </header>
+
+ <main>
+ <div class="tab-report-grid">
+ <div class="grid-item tab">
+ <table id="report-table">
+ <thead>
+ <tr>
+ <th>Testcase</th>
+ <th>Result</th>
+ <th>Skipped</th>
+ <th>Orphans</th>
+ <th>Duration</th>
+ <th class="report-column">Report</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${report_table_tests}
+ </tbody>
+ </table>
+ </div>
+ <div id="report_area" class="grid-item tab">
+ <h4>Failure Report</h4>
+ <div class="report-column"></div>
+ </div>
+ </div>
+ </main>
+
+ <footer>
+ <p>Generated by <a href="https://github.com/MikeSchulze/gdUnit4">GdUnit4</a> at ${buid_date}</p>
+ <div class="status-legend">
+ <span class="status-legend-item">
+ <span class="status-box status-skipped"></span> Skipped
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-passed"></span> Passed
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-flaky"></span> Flaky
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-warning"></span> Warning
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-failed"></span> Failed
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-error"></span> Error
+ </span>
+ </div>
+ </footer>
+</body>
+
+<script>
+ function groupTableRows() {
+ const table = document.getElementById('report-table');
+ const rows = table.querySelectorAll('tbody tr');
+
+ let previousTestCase = '';
+ let groupStartIndex = 0;
+ let groupColorToggle = true; // Toggle between two colors
+
+ rows.forEach((row, index) => {
+ const testCaseName = row.cells[0].textContent.trim();
+
+ if (testCaseName !== previousTestCase) {
+ // Close the previous group
+ if (index > 0 && groupStartIndex !== index - 1) {
+ // Apply background color to the group
+ const groupClass = groupColorToggle ? 'group-bg-1' : 'group-bg-2';
+ for (let i = groupStartIndex; i <= index - 1; i++) {
+ rows[i].classList.add(groupClass);
+ }
+
+ // Toggle background color for the next group
+ groupColorToggle = !groupColorToggle;
+ }
+
+ // Start a new group
+ groupStartIndex = index;
+ }
+
+ previousTestCase = testCaseName;
+ });
+
+ // Handle the last group
+ if (groupStartIndex !== rows.length - 1) {
+ // Apply background color to the last group
+ const groupClass = groupColorToggle ? 'group-bg-1' : 'group-bg-2';
+ for (let i = groupStartIndex; i < rows.length; i++) {
+ rows[i].classList.add(groupClass);
+ }
+ }
+ }
+
+ // Call the function to group table rows after the DOM loads
+ document.addEventListener('DOMContentLoaded', groupTableRows);
+</script>
+
+</html>
diff --git a/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd
new file mode 100644
index 0000000..0b9674f
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd
@@ -0,0 +1,143 @@
+# 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 := '<?xml version="1.0" encoding="UTF-8" ?>\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(convert_rtf_to_text(report.message())))
+ 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(convert_rtf_to_text(report.message())))
+ 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 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..16ffa18
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid
@@ -0,0 +1 @@
+uid://bf8g04wtq4lfp
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}</{name}>\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 "<![CDATA[\n{text}]]>\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..f02b6a2
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid
@@ -0,0 +1 @@
+uid://bae36annbua3k
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(<instance>)'" % [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, "<empty>");
+ 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..3d7291e
--- /dev/null
+++ b/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd.uid
@@ -0,0 +1 @@
+uid://b6d5ybx2tm8kb
diff --git a/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd
new file mode 100644
index 0000000..e0bcaf2
--- /dev/null
+++ b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd
@@ -0,0 +1,46 @@
+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:
+ 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..ed7f83f
--- /dev/null
+++ b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd.uid
@@ -0,0 +1 @@
+uid://cw054vthlt0xe
diff --git a/addons/gdUnit4/src/ui/GdUnitConsole.gd b/addons/gdUnit4/src/ui/GdUnitConsole.gd
new file mode 100644
index 0000000..eb1e625
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitConsole.gd
@@ -0,0 +1,91 @@
+@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
+
+
+@warning_ignore("return_value_discarded")
+func _ready() -> void:
+ GdUnitFonts.init_fonts(output)
+ 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 == EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED:
+ _test_reporter.init_colors()
+ 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!", Color.INDIAN_RED)
+ _test_reporter.println_message("Error: %s" % response.response(), Color.INDIAN_RED)
+ 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.", Color.FOREST_GREEN)
+ return
+
+ _test_reporter.println_message("A new update is available %s" % latest_version, Color.YELLOW)
+ _test_reporter.println_message("Open the GdUnit4 settings and check the update tab.", Color.YELLOW)
+
+ control.icon = GdUnitUiTools.get_icon("Notification", Color.YELLOW)
+ 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", Color.YELLOW, .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, Color.hex(0x9887c4))
+
+
+func _on_gdunit_client_disconnected(client_id: int) -> void:
+ _test_reporter.println_message("GdUnit Test Client disconnected with id: %d" % client_id, Color.hex(0x9887c4))
+
+
+func _on_gdunit_message(message: String) -> void:
+ _test_reporter.println_message(message, Color.CORNFLOWER_BLUE)
diff --git a/addons/gdUnit4/src/ui/GdUnitConsole.gd.uid b/addons/gdUnit4/src/ui/GdUnitConsole.gd.uid
new file mode 100644
index 0000000..f3327c6
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitConsole.gd.uid
@@ -0,0 +1 @@
+uid://yk3dmu2vt5w2
diff --git a/addons/gdUnit4/src/ui/GdUnitConsole.tscn b/addons/gdUnit4/src/ui/GdUnitConsole.tscn
new file mode 100644
index 0000000..25f45b5
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitConsole.tscn
@@ -0,0 +1,64 @@
+[gd_scene format=3 uid="uid://dm0wvfyeew7vd"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/GdUnitConsole.gd" id="1"]
+
+[node name="Control" type="Control"]
+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="."]
+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="PanelContainer" parent="VBoxContainer"]
+auto_translate_mode = 2
+custom_minimum_size = Vector2(0, 32)
+layout_mode = 2
+localize_numeral_system = false
+mouse_filter = 2
+
+[node name="header_title" type="RichTextLabel" parent="VBoxContainer/Header"]
+auto_translate_mode = 2
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+localize_numeral_system = false
+mouse_filter = 2
+bbcode_enabled = true
+scroll_active = false
+autowrap_mode = 0
+shortcut_keys_enabled = false
+
+[node name="Console" type="ScrollContainer" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="TextEdit" type="RichTextLabel" parent="VBoxContainer/Console"]
+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..0cb0f5d
--- /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..f94bc32
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitFonts.gd.uid
@@ -0,0 +1 @@
+uid://cxol0xus2k25t
diff --git a/addons/gdUnit4/src/ui/GdUnitInspector.gd b/addons/gdUnit4/src/ui/GdUnitInspector.gd
new file mode 100644
index 0000000..81d896f
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitInspector.gd
@@ -0,0 +1,34 @@
+@tool
+class_name GdUnitInspecor
+extends Panel
+
+
+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)
+ )
+
+ # propagete the test_counters_changed signal to the progress bar
+ @warning_ignore("unsafe_property_access", "unsafe_method_access")
+ %MainPanel.test_counters_changed.connect(%ProgressBar._on_test_counter_changed)
+
+
+func _process(delta: float) -> void:
+ _wait_time += delta
+ if _wait_time > 5.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..8f4c2ab
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitInspector.gd.uid
@@ -0,0 +1 @@
+uid://b26rlm46xtlm6
diff --git a/addons/gdUnit4/src/ui/GdUnitInspector.tscn b/addons/gdUnit4/src/ui/GdUnitInspector.tscn
new file mode 100644
index 0000000..cbaf7c9
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitInspector.tscn
@@ -0,0 +1,70 @@
+[gd_scene format=3 uid="uid://mpo5o6d4uybu"]
+
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/InspectorToolBar.tscn" id="1"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/InspectorProgressBar.tscn" id="2"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/InspectorStatusBar.tscn" id="3"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/InspectorMonitor.tscn" id="4"]
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/GdUnitInspector.gd" id="5"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/InspectorTreePanel.tscn" id="7"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/network/GdUnitServer.tscn" id="7_721no"]
+
+[node name="GdUnit" type="Panel"]
+use_parent_material = true
+clip_contents = true
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+size_flags_horizontal = 11
+size_flags_vertical = 3
+focus_mode = 2
+script = ExtResource("5")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+use_parent_material = true
+clip_contents = true
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+size_flags_vertical = 11
+theme_override_constants/separation = 0
+
+[node name="Header" type="VBoxContainer" parent="VBoxContainer"]
+use_parent_material = true
+clip_contents = true
+layout_mode = 2
+size_flags_horizontal = 9
+size_flags_vertical = 0
+
+[node name="ToolBar" parent="VBoxContainer/Header" instance=ExtResource("1")]
+layout_mode = 2
+size_flags_vertical = 1
+
+[node name="ProgressBar" parent="VBoxContainer/Header" instance=ExtResource("2")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 5
+max_value = 0.0
+
+[node name="StatusBar" parent="VBoxContainer/Header" instance=ExtResource("3")]
+layout_mode = 2
+size_flags_horizontal = 11
+
+[node name="MainPanel" parent="VBoxContainer" instance=ExtResource("7")]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="Monitor" parent="VBoxContainer" instance=ExtResource("4")]
+layout_mode = 2
+
+[node name="event_server" parent="." 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_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"]
+[connection signal="jump_to_orphan_nodes" from="VBoxContainer/Monitor" to="VBoxContainer/MainPanel" method="select_first_orphan"]
diff --git a/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd
new file mode 100644
index 0000000..8dd0265
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd
@@ -0,0 +1,31 @@
+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,
+}
diff --git a/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid
new file mode 100644
index 0000000..463a301
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid
@@ -0,0 +1 @@
+uid://bfaaukho0a14
diff --git a/addons/gdUnit4/src/ui/GdUnitUiTools.gd b/addons/gdUnit4/src/ui/GdUnitUiTools.gd
new file mode 100644
index 0000000..0bcdb1d
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitUiTools.gd
@@ -0,0 +1,151 @@
+class_name GdUnitUiTools
+extends RefCounted
+
+
+static var _spinner: AnimatedTexture
+
+
+enum ImageFlipMode {
+ HORIZONTAl,
+ VERITCAL
+}
+
+
+## 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, .2)
+ texture.set_frame_texture(frame, get_icon(icon_name, color))
+ texture.set_frame_duration(frame, 0.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 get_GDScript_icon(status: String, color: Color) -> Texture2D:
+ if not Engine.is_editor_hint():
+ return null
+ var icon_a := EditorInterface.get_base_control().get_theme_icon("GDScript", "EditorIcons")
+ var icon_b := EditorInterface.get_base_control().get_theme_icon(status, "EditorIcons")
+ var overlay_image := _modulate_image(icon_b.get_image(), color)
+ var image := _merge_images_scaled(icon_a.get_image(), Vector2i(0, 0), overlay_image, Vector2i(5, 5))
+ return ImageTexture.create_from_image(image)
+
+
+static func get_CSharpScript_icon(status: String, color: Color) -> Texture2D:
+ if not Engine.is_editor_hint():
+ return null
+ var icon_a := EditorInterface.get_base_control().get_theme_icon("CSharpScript", "EditorIcons")
+ var icon_b := EditorInterface.get_base_control().get_theme_icon(status, "EditorIcons")
+ var overlay_image := _modulate_image(icon_b.get_image(), color)
+ var image := _merge_images_scaled(icon_a.get_image(), Vector2i(0, 0), overlay_image, Vector2i(5, 5))
+ 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] = pixel_a.a8
+ 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_mask(image1, image2, Rect2(Vector2.ZERO, image1.get_size()), offset1)
+ merged_image.blit_rect_mask(image1, 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.blend_rect(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..4d4f2be
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitUiTools.gd.uid
@@ -0,0 +1 @@
+uid://cv34ysw4mo25c
diff --git a/addons/gdUnit4/src/ui/ScriptEditorControls.gd b/addons/gdUnit4/src/ui/ScriptEditorControls.gd
new file mode 100644
index 0000000..5e07fe1
--- /dev/null
+++ b/addons/gdUnit4/src/ui/ScriptEditorControls.gd
@@ -0,0 +1,100 @@
+# A tool to provide extended script editor functionallity
+class_name ScriptEditorControls
+extends RefCounted
+
+# https://github.com/godotengine/godot/blob/master/editor/plugins/script_editor_plugin.h
+# the Editor menu popup items
+enum {
+ FILE_NEW,
+ FILE_NEW_TEXTFILE,
+ FILE_OPEN,
+ FILE_REOPEN_CLOSED,
+ FILE_OPEN_RECENT,
+ FILE_SAVE,
+ FILE_SAVE_AS,
+ FILE_SAVE_ALL,
+ FILE_THEME,
+ FILE_RUN,
+ FILE_CLOSE,
+ CLOSE_DOCS,
+ CLOSE_ALL,
+ CLOSE_OTHER_TABS,
+ TOGGLE_SCRIPTS_PANEL,
+ SHOW_IN_FILE_SYSTEM,
+ FILE_COPY_PATH,
+ FILE_TOOL_RELOAD_SOFT,
+ SEARCH_IN_FILES,
+ REPLACE_IN_FILES,
+ SEARCH_HELP,
+ SEARCH_WEBSITE,
+ HELP_SEARCH_FIND,
+ HELP_SEARCH_FIND_NEXT,
+ HELP_SEARCH_FIND_PREVIOUS,
+ WINDOW_MOVE_UP,
+ WINDOW_MOVE_DOWN,
+ WINDOW_NEXT,
+ WINDOW_PREV,
+ WINDOW_SORT,
+ WINDOW_SELECT_BASE = 100
+}
+
+
+# Saves the given script and closes if requested by <close=true>
+# The script is saved when is opened in the editor.
+# The script is closed when <close> is set to true.
+static func save_an_open_script(script_path: String, close:=false) -> bool:
+ #prints("save_an_open_script", script_path, close)
+ if !Engine.is_editor_hint():
+ return false
+ var editor := EditorInterface.get_script_editor()
+ var editor_popup := _menu_popup()
+ # 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);
+ # save and close
+ editor_popup.id_pressed.emit(FILE_SAVE)
+ if close:
+ editor_popup.id_pressed.emit(FILE_CLOSE)
+ return true
+ return false
+
+
+# Saves all opened script
+static func save_all_open_script() -> void:
+ if Engine.is_editor_hint():
+ _menu_popup().id_pressed.emit(FILE_SAVE_ALL)
+
+
+static func close_open_editor_scripts() -> void:
+ if Engine.is_editor_hint():
+ _menu_popup().id_pressed.emit(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:
+ var file_system := EditorInterface.get_resource_filesystem()
+ file_system.update_file(script_path)
+ 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 _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 _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/ScriptEditorControls.gd.uid b/addons/gdUnit4/src/ui/ScriptEditorControls.gd.uid
new file mode 100644
index 0000000..d9eeb69
--- /dev/null
+++ b/addons/gdUnit4/src/ui/ScriptEditorControls.gd.uid
@@ -0,0 +1 @@
+uid://bp8qk8bp1l8gm
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..03b589b
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd.uid
@@ -0,0 +1 @@
+uid://7xcqjry47b4y
diff --git a/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd b/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd
new file mode 100644
index 0000000..0c92b92
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd
@@ -0,0 +1,37 @@
+@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
+ _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)
+ ))
+
+ # setup shortcuts
+ for menu_item in _context_menus:
+ if menu_item.shortcut():
+ var cb := func call(...files: Array) -> void:
+ var paths: Array = files[0]
+ menu_item.execute.callv(paths)
+ add_menu_shortcut(menu_item.shortcut(), cb)
+
+
+func _popup_menu(paths: PackedStringArray) -> void:
+ for menu_item in _context_menus:
+ if menu_item.shortcut():
+ 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(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..fde0854
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd.uid
@@ -0,0 +1 @@
+uid://bgas7gt1plcao
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..f1c00de
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd.uid
@@ -0,0 +1 @@
+uid://ch44lt0cpk77q
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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M4 12a1 1 0 0 0 1.555.832l6-4a1 1 0 0 0 0-1.664l-6-4A1 1 0 0 0 4 4z\"/></svg>
+"
+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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M3 3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm4.975 0A1 1 0 0 0 7 3.998v8a1 1 0 0 0 1.625.781l5-4a1 1 0 0 0 0-1.56l-5-4A1 1 0 0 0 7.975 3z\"/></svg>
+"
+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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"m8 9.669-3.536 2.583H7v2.537h2v-2.537h2.536zm0-3.314L4.464 3.772H7V1.235h2v2.537h2.536zm-7.296.73h14.591v1.831H.704z\"/></svg>
+"
+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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"m8 16-3.5-2.5H7V11h2v2.5h2.5zM8 0 4.5 2.5H7V5h2V2.5h2.5zM1 7h14v2H1z\"/></svg>
+"
+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..26f5612
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd
@@ -0,0 +1,41 @@
+@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
+ _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)
+ ))
+
+ # setup shortcuts
+ for menu_item in _context_menus:
+ if menu_item.shortcut():
+ add_menu_shortcut(menu_item.shortcut(), menu_item.execute.unbind(1))
+
+
+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_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..8ab0661
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd.uid
@@ -0,0 +1 @@
+uid://f40ddt1acw6h
diff --git a/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd b/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd
new file mode 100644
index 0000000..c36b3ec
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd
@@ -0,0 +1,54 @@
+@tool
+extends PanelContainer
+
+signal jump_to_orphan_nodes()
+
+@onready var ICON_GREEN := GdUnitUiTools.get_icon("Unlinked", Color.WEB_GREEN)
+@onready var ICON_RED := GdUnitUiTools.get_color_animated_icon("Unlinked", Color.YELLOW, Color.ORANGE_RED)
+
+@onready var _button_time: Button = %btn_time
+@onready var _time: Label = %time_value
+@onready var _orphans: Label = %orphan_value
+@onready var _orphan_button: Button = %btn_orphan
+
+var total_elapsed_time := 0
+var total_orphans := 0
+
+
+func _ready() -> void:
+ @warning_ignore("return_value_discarded")
+ GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event)
+ _time.text = ""
+ _orphans.text = "0"
+ _button_time.icon = GdUnitUiTools.get_icon("Time")
+ _orphan_button.icon = ICON_GREEN
+
+
+func status_changed(elapsed_time: int, orphan_nodes: int) -> void:
+ total_elapsed_time += elapsed_time
+ total_orphans += orphan_nodes
+ _time.text = LocalTime.elapsed(total_elapsed_time)
+ _orphans.text = str(total_orphans)
+ if total_orphans > 0:
+ _orphan_button.icon = ICON_RED
+
+
+func _on_gdunit_event(event: GdUnitEvent) -> void:
+ match event.type():
+ GdUnitEvent.INIT:
+ _orphan_button.icon = ICON_GREEN
+ total_elapsed_time = 0
+ total_orphans = 0
+ status_changed(0, 0)
+ GdUnitEvent.TESTCASE_BEFORE:
+ pass
+ GdUnitEvent.TESTCASE_AFTER:
+ status_changed(0, event.orphan_nodes())
+ GdUnitEvent.TESTSUITE_BEFORE:
+ pass
+ GdUnitEvent.TESTSUITE_AFTER:
+ status_changed(event.elapsed_time(), event.orphan_nodes())
+
+
+func _on_ToolButton_pressed() -> void:
+ jump_to_orphan_nodes.emit()
diff --git a/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd.uid
new file mode 100644
index 0000000..ea72742
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd.uid
@@ -0,0 +1 @@
+uid://buiiqjcucwy8g
diff --git a/addons/gdUnit4/src/ui/parts/InspectorMonitor.tscn b/addons/gdUnit4/src/ui/parts/InspectorMonitor.tscn
new file mode 100644
index 0000000..0262b8c
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorMonitor.tscn
@@ -0,0 +1,94 @@
+[gd_scene load_steps=6 format=3 uid="uid://djp8ait0bxpsc"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/InspectorMonitor.gd" id="3"]
+
+[sub_resource type="Image" id="Image_sx31i"]
+data = {
+"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 4, 227, 227, 227, 36, 227, 227, 227, 36, 255, 255, 255, 4, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 131, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 131, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 5, 225, 225, 225, 76, 224, 224, 224, 255, 224, 224, 224, 255, 226, 226, 226, 77, 255, 255, 255, 5, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 99, 224, 224, 224, 232, 224, 224, 224, 244, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 244, 224, 224, 224, 233, 224, 224, 224, 97, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 225, 225, 225, 135, 224, 224, 224, 247, 224, 224, 224, 115, 234, 234, 234, 12, 224, 224, 224, 130, 224, 224, 224, 130, 234, 234, 234, 12, 225, 225, 225, 116, 224, 224, 224, 248, 224, 224, 224, 132, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 226, 226, 226, 77, 224, 224, 224, 251, 224, 224, 224, 64, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 66, 224, 224, 224, 252, 225, 225, 225, 75, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 201, 224, 224, 224, 146, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 2, 224, 224, 224, 146, 224, 224, 224, 106, 255, 255, 255, 0, 225, 225, 225, 150, 224, 224, 224, 195, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 24, 224, 224, 224, 255, 226, 226, 226, 43, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 233, 233, 233, 23, 225, 225, 225, 166, 224, 224, 224, 237, 228, 228, 228, 47, 255, 255, 255, 0, 225, 225, 225, 51, 224, 224, 224, 255, 224, 224, 224, 16, 255, 255, 255, 0, 255, 255, 255, 0, 225, 225, 225, 67, 224, 224, 224, 255, 225, 225, 225, 215, 227, 227, 227, 9, 255, 255, 255, 0, 255, 255, 255, 0, 223, 223, 223, 239, 224, 224, 224, 253, 224, 224, 224, 49, 255, 255, 255, 0, 230, 230, 230, 30, 224, 224, 224, 230, 224, 224, 224, 255, 224, 224, 224, 49, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 41, 224, 224, 224, 255, 225, 225, 225, 101, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 139, 224, 224, 224, 139, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 5, 225, 225, 225, 117, 224, 224, 224, 255, 224, 224, 224, 33, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 6, 224, 224, 224, 240, 226, 226, 226, 87, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 96, 224, 224, 224, 236, 255, 255, 255, 3, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 225, 225, 225, 143, 224, 224, 224, 211, 224, 224, 224, 8, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 232, 232, 232, 11, 224, 224, 224, 216, 225, 225, 225, 141, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 238, 238, 238, 15, 224, 224, 224, 220, 224, 224, 224, 178, 238, 238, 238, 15, 255, 255, 255, 0, 225, 225, 225, 51, 225, 225, 225, 51, 255, 255, 255, 0, 227, 227, 227, 18, 224, 224, 224, 184, 224, 224, 224, 218, 238, 238, 238, 15, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 227, 227, 227, 36, 224, 224, 224, 212, 224, 224, 224, 232, 225, 225, 225, 133, 224, 224, 224, 251, 224, 224, 224, 240, 225, 225, 225, 135, 224, 224, 224, 234, 224, 224, 224, 208, 225, 225, 225, 34, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 230, 230, 230, 10, 224, 224, 224, 107, 224, 224, 224, 197, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 196, 224, 224, 224, 104, 224, 224, 224, 8, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_ugpqy"]
+image = SubResource("Image_sx31i")
+
+[sub_resource type="Image" id="Image_gkq5u"]
+data = {
+"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 251, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 234, 22, 138, 22, 247, 22, 138, 22, 253, 22, 138, 22, 253, 22, 138, 22, 247, 22, 138, 22, 233, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 251, 22, 138, 22, 236, 255, 255, 255, 0, 22, 138, 22, 255, 255, 255, 255, 0, 23, 138, 23, 233, 22, 138, 22, 254, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 253, 23, 138, 23, 233, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 236, 22, 138, 22, 253, 22, 138, 22, 236, 22, 138, 22, 251, 255, 255, 255, 0, 22, 138, 22, 247, 22, 138, 22, 255, 22, 138, 22, 248, 22, 138, 22, 233, 23, 138, 23, 233, 22, 138, 22, 249, 22, 138, 22, 255, 22, 138, 22, 246, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 236, 22, 138, 22, 251, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 249, 22, 138, 22, 253, 23, 138, 23, 232, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 234, 22, 138, 22, 255, 22, 138, 22, 253, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 251, 22, 138, 22, 255, 22, 138, 22, 251, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 24, 139, 24, 231, 23, 138, 23, 231, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 234, 22, 138, 22, 255, 22, 138, 22, 253, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 231, 23, 138, 23, 234, 22, 138, 22, 249, 22, 138, 22, 255, 22, 138, 22, 246, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 233, 22, 138, 22, 247, 22, 138, 22, 249, 24, 139, 24, 231, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 245, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 253, 23, 138, 23, 233, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 234, 22, 138, 22, 254, 22, 138, 22, 255, 22, 138, 22, 253, 23, 138, 23, 231, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 241, 22, 138, 22, 253, 22, 138, 22, 253, 22, 138, 22, 246, 22, 138, 22, 233, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 247, 22, 138, 22, 255, 22, 138, 22, 248, 23, 138, 23, 232, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 245, 23, 138, 23, 241, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 253, 22, 138, 22, 255, 22, 138, 22, 233, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 231, 22, 138, 22, 255, 22, 138, 22, 253, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 253, 22, 138, 22, 255, 23, 138, 23, 233, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 234, 22, 138, 22, 255, 22, 138, 22, 253, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 247, 22, 138, 22, 255, 22, 138, 22, 249, 22, 138, 22, 234, 23, 138, 23, 234, 22, 138, 22, 249, 22, 138, 22, 255, 22, 138, 22, 246, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 233, 22, 138, 22, 253, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 253, 22, 138, 22, 233, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 233, 22, 138, 22, 246, 22, 138, 22, 253, 22, 138, 22, 253, 22, 138, 22, 246, 23, 138, 23, 233, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_nj5du"]
+image = SubResource("Image_gkq5u")
+
+[node name="Monitor" type="PanelContainer"]
+clip_contents = true
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_right = -793.0
+offset_bottom = -564.0
+size_flags_horizontal = 9
+size_flags_vertical = 9
+script = ExtResource("3")
+
+[node name="HBoxContainer" type="HBoxContainer" parent="."]
+layout_mode = 2
+size_flags_vertical = 4
+
+[node name="timer" type="HBoxContainer" parent="HBoxContainer"]
+layout_mode = 2
+
+[node name="btn_time" type="Button" parent="HBoxContainer/timer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 4
+auto_translate = false
+localize_numeral_system = false
+tooltip_text = "Shows the total elapsed time of test execution."
+mouse_force_pass_scroll_events = false
+button_mask = 0
+shortcut_feedback = false
+shortcut_in_tooltip = false
+text = "Time"
+icon = SubResource("ImageTexture_ugpqy")
+flat = true
+
+[node name="time_value" type="Label" parent="HBoxContainer/timer"]
+unique_name_in_owner = true
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 3
+auto_translate = false
+localize_numeral_system = false
+max_lines_visible = 1
+
+[node name="orphan" type="HBoxContainer" parent="HBoxContainer/timer"]
+layout_mode = 2
+
+[node name="btn_orphan" type="Button" parent="HBoxContainer/timer/orphan"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 4
+auto_translate = false
+localize_numeral_system = false
+tooltip_text = "Shows the total orphan nodes detected."
+text = "Orphans"
+icon = SubResource("ImageTexture_nj5du")
+
+[node name="orphan_value" type="Label" parent="HBoxContainer/timer/orphan"]
+unique_name_in_owner = true
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 3
+auto_translate = false
+localize_numeral_system = false
+text = "0"
+max_lines_visible = 1
diff --git a/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd
new file mode 100644
index 0000000..d368ed3
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd
@@ -0,0 +1,49 @@
+@tool
+extends ProgressBar
+
+
+@onready var status: Label = $Label
+@onready var style: StyleBoxFlat = get("theme_override_styles/fill")
+
+var _state: GdUnitInspectorTreeConstants.STATE
+
+func _ready() -> void:
+ style.bg_color = Color.DARK_GREEN
+ value = 0
+ max_value = 0
+ update_text()
+
+
+func update_text() -> void:
+ status.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:
+ style.bg_color = Color.DARK_GREEN
+
+ # do only update the state is higher prio than current state
+ if state <= _state:
+ return
+ _state = state
+
+ if is_flaky(state):
+ style.bg_color = Color.WEB_GREEN
+ if is_failed(state):
+ style.bg_color = Color.DARK_RED
+
+
+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/InspectorProgressBar.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd.uid
new file mode 100644
index 0000000..61df203
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd.uid
@@ -0,0 +1 @@
+uid://de8bjgxoj10u3
diff --git a/addons/gdUnit4/src/ui/parts/InspectorProgressBar.tscn b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.tscn
new file mode 100644
index 0000000..1824230
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.tscn
@@ -0,0 +1,33 @@
+[gd_scene load_steps=3 format=3 uid="uid://dva3tonxsxrlk"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd" id="1"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ayfir"]
+bg_color = Color(0, 0.392157, 0, 1)
+
+[node name="ProgressBar" type="ProgressBar"]
+custom_minimum_size = Vector2(0, 20)
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_vertical = 9
+theme_override_styles/fill = SubResource("StyleBoxFlat_ayfir")
+rounded = true
+allow_greater = true
+show_percentage = false
+script = ExtResource("1")
+
+[node name="Label" type="Label" parent="."]
+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_vertical = 3
+horizontal_alignment = 1
+vertical_alignment = 1
+max_lines_visible = 1
diff --git a/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd
new file mode 100644
index 0000000..1863459
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd
@@ -0,0 +1,209 @@
+@tool
+extends PanelContainer
+
+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()
+
+@warning_ignore("unused_signal")
+signal tree_view_mode_changed(flat :bool)
+
+@onready var _errors: Label = %error_value
+@onready var _failures: Label = %failure_value
+@onready var _flaky_value: Label = %flaky_value
+@onready var _skipped_value: Label = %skipped_value
+#@onready var _button_failure_up: Button = %btn_failure_up
+#@onready var _button_failure_down: Button = %btn_failure_down
+@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_errors: TextureRect = %icon_errors
+@onready var _icon_failures: TextureRect = %icon_failures
+@onready var _icon_flaky: TextureRect = %icon_flaky
+@onready var _icon_skipped: TextureRect = %icon_skipped
+
+var total_failed := 0
+var total_errors := 0
+var total_flaky := 0
+var total_skipped := 0
+
+
+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:
+ _failures.text = "0"
+ _errors.text = "0"
+ _flaky_value.text = "0"
+ _skipped_value.text = "0"
+ _icon_failures.texture = GdUnitUiTools.get_icon("StatusError", Color.SKY_BLUE)
+ _icon_errors.texture = GdUnitUiTools.get_icon("StatusError", Color.DARK_RED)
+ _icon_flaky.texture = GdUnitUiTools.get_icon("CheckBox", Color.GREEN_YELLOW)
+ _icon_skipped.texture = GdUnitUiTools.get_icon("CheckBox", Color.WEB_GRAY)
+
+ #_button_failure_up.icon = GdUnitUiTools.get_icon("ArrowUp")
+ #_button_failure_down.icon = GdUnitUiTools.get_icon("ArrowDown")
+ _button_sync.icon = GdUnitUiTools.get_icon("Loop")
+ _set_sort_mode_menu_options()
+ _set_view_mode_menu_options()
+ GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event)
+ GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed)
+
+
+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) -> void:
+ total_failed += failed
+ total_errors += errors
+ total_flaky += flaky
+ total_skipped += skipped
+ _failures.text = str(total_failed)
+ _errors.text = str(total_errors)
+ _flaky_value.text = str(total_flaky)
+ _skipped_value.text = str(total_skipped)
+
+
+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
+ status_changed(total_errors, total_failed, total_flaky, total_skipped)
+
+ GdUnitEvent.TESTCASE_AFTER:
+ status_changed(event.error_count(), event.failed_count(), event.is_flaky(), event.is_skipped())
+
+ GdUnitEvent.TESTSUITE_AFTER:
+ status_changed(event.error_count(), event.failed_count(), event.is_flaky(), 0)
+
+ GdUnitEvent.SESSION_START:
+ disable_buttons(true)
+
+ GdUnitEvent.SESSION_CLOSE:
+ disable_buttons(false)
+
+
+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_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/InspectorStatusBar.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd.uid
new file mode 100644
index 0000000..e4c2c25
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd.uid
@@ -0,0 +1 @@
+uid://c0whlxhcst61i
diff --git a/addons/gdUnit4/src/ui/parts/InspectorStatusBar.tscn b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.tscn
new file mode 100644
index 0000000..d4ed816
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.tscn
@@ -0,0 +1,465 @@
+[gd_scene load_steps=26 format=3 uid="uid://c22l4odk7qesc"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd" id="3"]
+
+[sub_resource type="DPITexture" id="DPITexture_mb3ih"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M8 1v2H6a5 5 0 0 0-4 8l1.414-1.414A3 3 0 0 1 6 5h2v2l4-3-4-3zm6 4-1.414 1.414A3 3 0 0 1 10 11H8V9l-4 3 4 3v-2h2a5 5 0 0 0 4-8z\"/></svg>
+"
+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(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_wo03e"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M9 1v2h6V1zM4 1a1 1 0 0 0-.691.291l-2 2a1 1 0 0 0 1.414 1.414l.293-.293v7.172l-.293-.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l2-2a1 1 0 0 0-1.414-1.414l-.293.293V4.412l.293.293a1 1 0 0 0 1.414-1.414l-2-2A1 1 0 0 0 4 1zm5 6v2h4V7zm0 6v2h2v-2z\"/></svg>
+"
+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(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_ixycx"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"none\" stroke=\"#e0e0e0\" d=\"M2 4h12zm0 4h12zm0 4h12z\"/></svg>
+"
+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(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="Image" id="Image_c80wp"]
+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, 231, 231, 231, 21, 224, 224, 224, 194, 224, 224, 224, 196, 232, 232, 232, 22, 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, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 212, 232, 232, 232, 22, 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, 224, 224, 224, 194, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 197, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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, 176, 224, 224, 224, 200, 224, 224, 224, 253, 224, 224, 224, 255, 225, 225, 225, 199, 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, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 252, 224, 224, 224, 255, 255, 255, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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, 252, 224, 224, 224, 255, 255, 255, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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, 252, 224, 224, 224, 255, 255, 255, 255, 4, 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, 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, 252, 224, 224, 224, 255, 255, 255, 255, 4, 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, 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, 252, 224, 224, 224, 255, 255, 255, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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, 252, 224, 224, 224, 255, 255, 255, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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, 171, 224, 224, 224, 195, 224, 224, 224, 253, 224, 224, 224, 255, 224, 224, 224, 195, 225, 225, 225, 175, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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, 196, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 233, 233, 233, 23, 224, 224, 224, 213, 224, 224, 224, 255, 224, 224, 224, 255, 225, 225, 225, 215, 224, 224, 224, 24, 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, 233, 233, 233, 23, 224, 224, 224, 198, 224, 224, 224, 201, 224, 224, 224, 24, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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_eis20"]
+image = SubResource("Image_c80wp")
+
+[sub_resource type="DPITexture" id="DPITexture_t2qd7"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M5 8a4 4 0 1 1 4 4v2a6 6 0 1 0-6-6H1l3 4 3-4zm3-3h2v2h2v2H8z\"/></svg>
+"
+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(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="Image" id="Image_jh28t"]
+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, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 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, 237, 247, 245, 255, 237, 247, 245, 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, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 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, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 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, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 248, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 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, 237, 247, 245, 255, 237, 247, 245, 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, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 248, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 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),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_1mh1t"]
+image = SubResource("Image_jh28t")
+
+[sub_resource type="Image" id="Image_lpjla"]
+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, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 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, 237, 247, 245, 255, 237, 247, 245, 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, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 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, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 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, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 248, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 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, 237, 247, 245, 255, 237, 247, 245, 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, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 248, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 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),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_bq8kn"]
+image = SubResource("Image_lpjla")
+
+[sub_resource type="Image" id="Image_bwbka"]
+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, 249, 249, 255, 230, 246, 246, 252, 230, 249, 249, 255, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 246, 252, 237, 246, 246, 252, 255, 246, 246, 252, 248, 0, 0, 0, 0, 246, 246, 252, 254, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 246, 252, 236, 246, 246, 252, 254, 246, 246, 252, 247, 0, 0, 0, 0, 246, 246, 252, 254, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 246, 253, 231, 246, 246, 253, 232, 246, 246, 252, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 246, 252, 243, 246, 246, 252, 255, 246, 246, 252, 242, 246, 246, 252, 230, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 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, 246, 246, 252, 242, 246, 246, 252, 253, 246, 246, 252, 241, 246, 246, 252, 230, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 246, 252, 244, 246, 246, 252, 255, 246, 246, 252, 241, 246, 246, 252, 230, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 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, 246, 246, 252, 244, 246, 246, 252, 255, 246, 246, 252, 241, 246, 246, 252, 230, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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_8lbfl"]
+image = SubResource("Image_bwbka")
+
+[sub_resource type="Image" id="Image_ki3oo"]
+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, 151, 12, 11, 232, 151, 12, 11, 242, 151, 12, 11, 250, 151, 12, 11, 254, 151, 12, 11, 254, 151, 12, 11, 250, 151, 12, 11, 242, 151, 12, 10, 232, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 10, 238, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 11, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 237, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 10, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 10, 232, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 240, 151, 12, 10, 234, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 11, 234, 151, 12, 11, 241, 151, 12, 11, 255, 151, 12, 11, 253, 151, 11, 10, 232, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 242, 151, 12, 11, 255, 151, 12, 11, 254, 151, 12, 10, 234, 0, 0, 0, 0, 151, 12, 10, 234, 151, 12, 11, 253, 151, 12, 11, 253, 151, 12, 11, 234, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 241, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 250, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 10, 234, 0, 0, 0, 0, 151, 12, 10, 234, 151, 12, 10, 234, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 250, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 10, 234, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 10, 234, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 10, 234, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 250, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 11, 234, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 10, 234, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 250, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 242, 151, 12, 11, 255, 151, 12, 11, 254, 151, 12, 11, 234, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 11, 253, 151, 12, 11, 253, 151, 12, 11, 234, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 10, 241, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 10, 232, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 241, 151, 12, 11, 234, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 11, 234, 151, 12, 11, 241, 151, 12, 11, 255, 151, 12, 11, 253, 151, 11, 10, 231, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 237, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 237, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 11, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 232, 151, 12, 11, 242, 151, 12, 11, 250, 151, 12, 11, 253, 151, 12, 11, 253, 151, 12, 11, 250, 151, 12, 11, 241, 151, 11, 10, 232, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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_ivm1h"]
+image = SubResource("Image_ki3oo")
+
+[sub_resource type="Image" id="Image_uqb0l"]
+data = {
+"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 255, 224, 224, 224, 255, 231, 231, 231, 21, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 211, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 210, 231, 231, 231, 21, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 211, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 210, 231, 231, 231, 21, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 211, 224, 224, 224, 255, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 210, 231, 231, 231, 21, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 195, 224, 224, 224, 255, 224, 224, 224, 210, 230, 230, 230, 20, 224, 224, 224, 255, 224, 224, 224, 255, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 194, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 178, 224, 224, 224, 194, 230, 230, 230, 20, 255, 255, 255, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 194, 224, 224, 224, 179, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 180, 224, 224, 224, 180, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_1oriu"]
+image = SubResource("Image_uqb0l")
+
+[sub_resource type="Image" id="Image_j00vj"]
+data = {
+"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 181, 224, 224, 224, 180, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 180, 224, 224, 224, 195, 231, 231, 231, 21, 255, 255, 255, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 195, 224, 224, 224, 178, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 195, 224, 224, 224, 255, 224, 224, 224, 210, 231, 231, 231, 21, 224, 224, 224, 255, 224, 224, 224, 255, 231, 231, 231, 21, 224, 224, 224, 211, 224, 224, 224, 255, 224, 224, 224, 194, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 211, 224, 224, 224, 255, 224, 224, 224, 210, 230, 230, 230, 20, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 210, 230, 230, 230, 20, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 210, 230, 230, 230, 20, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 255, 224, 224, 224, 255, 230, 230, 230, 20, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_ikyhk"]
+image = SubResource("Image_j00vj")
+
+[sub_resource type="Image" id="Image_0oden"]
+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, 147, 198, 223, 232, 147, 197, 222, 242, 147, 197, 222, 250, 147, 197, 222, 254, 147, 197, 222, 254, 147, 197, 222, 250, 147, 197, 222, 242, 147, 197, 222, 232, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 238, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 197, 222, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 198, 222, 237, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 232, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 240, 147, 198, 222, 234, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 198, 222, 234, 147, 197, 222, 241, 147, 197, 222, 255, 147, 197, 222, 253, 147, 197, 222, 232, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 242, 147, 197, 222, 255, 147, 197, 222, 254, 147, 198, 222, 234, 0, 0, 0, 0, 147, 198, 222, 234, 147, 197, 222, 253, 147, 197, 222, 253, 147, 197, 222, 234, 0, 0, 0, 0, 147, 197, 222, 234, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 241, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 250, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 198, 222, 234, 0, 0, 0, 0, 147, 198, 222, 234, 147, 198, 222, 234, 0, 0, 0, 0, 147, 197, 222, 234, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 250, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 198, 222, 234, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 234, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 198, 222, 234, 0, 0, 0, 0, 0, 0, 0, 0, 147, 198, 222, 234, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 250, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 197, 222, 234, 0, 0, 0, 0, 147, 197, 222, 234, 147, 198, 222, 234, 0, 0, 0, 0, 147, 197, 222, 234, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 250, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 242, 147, 197, 222, 255, 147, 197, 222, 254, 147, 198, 222, 234, 0, 0, 0, 0, 147, 197, 222, 234, 147, 197, 222, 253, 147, 197, 222, 253, 147, 197, 222, 234, 0, 0, 0, 0, 147, 197, 222, 234, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 241, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 232, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 241, 147, 197, 222, 234, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 197, 222, 234, 147, 197, 222, 241, 147, 197, 222, 255, 147, 197, 222, 253, 147, 197, 221, 231, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 198, 222, 237, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 237, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 197, 222, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 198, 222, 232, 147, 197, 222, 242, 147, 197, 222, 250, 147, 197, 222, 253, 147, 197, 222, 253, 147, 197, 222, 250, 147, 197, 222, 241, 147, 197, 222, 232, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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_suo5c"]
+image = SubResource("Image_0oden")
+
+[sub_resource type="Image" id="Image_ipq44"]
+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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 237, 170, 253, 57, 252, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 254, 58, 242, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 252, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 254, 58, 242, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 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, 170, 253, 58, 234, 170, 253, 57, 247, 171, 255, 57, 231, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 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, 170, 253, 58, 234, 170, 253, 57, 253, 170, 253, 57, 255, 170, 254, 57, 243, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 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, 170, 253, 58, 234, 170, 253, 57, 253, 170, 253, 57, 255, 170, 254, 57, 247, 171, 255, 58, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 170, 254, 58, 232, 170, 254, 57, 232, 0, 0, 0, 0, 170, 253, 58, 234, 170, 253, 57, 253, 170, 253, 57, 255, 170, 254, 57, 247, 171, 255, 58, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 170, 254, 58, 232, 170, 253, 57, 251, 170, 253, 57, 251, 170, 254, 58, 236, 170, 253, 57, 253, 170, 253, 57, 255, 170, 254, 57, 247, 171, 255, 58, 230, 0, 0, 0, 0, 170, 254, 58, 242, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 170, 254, 57, 232, 170, 253, 57, 251, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 254, 57, 247, 171, 255, 58, 230, 0, 0, 0, 0, 170, 254, 58, 242, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 170, 254, 57, 232, 170, 253, 57, 251, 170, 253, 57, 255, 170, 254, 57, 247, 171, 255, 58, 230, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 254, 57, 232, 170, 253, 57, 244, 171, 255, 58, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 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, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 252, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 252, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 254, 57, 237, 170, 253, 57, 252, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 252, 170, 254, 57, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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_d5kq4"]
+image = SubResource("Image_ipq44")
+
+[sub_resource type="Image" id="Image_8d0da"]
+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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 237, 129, 139, 130, 252, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 242, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 252, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 242, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 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, 129, 139, 131, 234, 129, 139, 130, 247, 130, 141, 130, 231, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 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, 129, 139, 131, 234, 129, 139, 130, 253, 129, 139, 130, 255, 129, 139, 130, 243, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 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, 129, 139, 131, 234, 129, 139, 130, 253, 129, 139, 130, 255, 129, 139, 130, 247, 131, 141, 131, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 130, 140, 131, 232, 129, 140, 130, 232, 0, 0, 0, 0, 129, 139, 131, 234, 129, 139, 130, 253, 129, 139, 130, 255, 129, 139, 130, 247, 131, 141, 131, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 130, 140, 131, 232, 129, 139, 130, 251, 129, 139, 130, 251, 129, 139, 130, 236, 129, 139, 130, 253, 129, 139, 130, 255, 129, 139, 130, 247, 131, 141, 131, 230, 0, 0, 0, 0, 129, 139, 130, 242, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 129, 140, 130, 232, 129, 139, 130, 251, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 247, 131, 141, 131, 230, 0, 0, 0, 0, 129, 139, 130, 242, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 130, 140, 130, 232, 129, 139, 130, 251, 129, 139, 130, 255, 129, 139, 130, 247, 131, 141, 131, 230, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 130, 140, 130, 232, 129, 139, 130, 244, 131, 141, 131, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 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, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 252, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 252, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 130, 139, 130, 237, 129, 139, 130, 252, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 252, 129, 139, 130, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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_qagbu"]
+image = SubResource("Image_8d0da")
+
+[node name="StatusBar" type="PanelContainer"]
+clip_contents = true
+anchors_preset = 10
+anchor_right = 1.0
+offset_right = -807.0
+offset_bottom = 31.0
+grow_horizontal = 2
+size_flags_horizontal = 3
+size_flags_vertical = 0
+script = ExtResource("3")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+layout_mode = 2
+size_flags_vertical = 0
+
+[node name="tree_tools" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 0
+
+[node name="Label" type="Label" parent="VBoxContainer/tree_tools"]
+layout_mode = 2
+size_flags_horizontal = 0
+text = "Statistics"
+
+[node name="tree_buttons" type="HBoxContainer" parent="VBoxContainer/tree_tools"]
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+alignment = 2
+
+[node name="VSeparator" type="VSeparator" parent="VBoxContainer/tree_tools/tree_buttons"]
+layout_mode = 2
+
+[node name="btn_tree_sync" type="Button" parent="VBoxContainer/tree_tools/tree_buttons"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Run discover tests."
+icon = SubResource("DPITexture_mb3ih")
+
+[node name="btn_tree_sort" type="MenuButton" parent="VBoxContainer/tree_tools/tree_buttons"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Sets tree sorting mode."
+icon = SubResource("DPITexture_wo03e")
+flat = false
+item_count = 4
+popup/item_0/text = "Unsorted"
+popup/item_0/icon = SubResource("DPITexture_ixycx")
+popup/item_0/checkable = 1
+popup/item_0/id = 0
+popup/item_1/text = "Name ascending"
+popup/item_1/icon = SubResource("DPITexture_wo03e")
+popup/item_1/checkable = 1
+popup/item_1/checked = true
+popup/item_1/id = 1
+popup/item_2/text = "Name descending"
+popup/item_2/icon = SubResource("ImageTexture_eis20")
+popup/item_2/checkable = 1
+popup/item_2/id = 2
+popup/item_3/text = "Execution time"
+popup/item_3/icon = SubResource("DPITexture_t2qd7")
+popup/item_3/checkable = 1
+popup/item_3/id = 3
+
+[node name="btn_tree_mode" type="MenuButton" parent="VBoxContainer/tree_tools/tree_buttons"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Sets tree presentation mode."
+icon = SubResource("ImageTexture_1mh1t")
+flat = false
+item_count = 2
+popup/item_0/text = "Tree"
+popup/item_0/icon = SubResource("ImageTexture_bq8kn")
+popup/item_0/checkable = 1
+popup/item_0/checked = true
+popup/item_0/id = 0
+popup/item_1/text = "Flat"
+popup/item_1/icon = SubResource("ImageTexture_8lbfl")
+popup/item_1/checkable = 1
+popup/item_1/id = 1
+
+[node name="HSeparator" type="HSeparator" parent="VBoxContainer"]
+layout_mode = 2
+theme_override_constants/separation = 0
+
+[node name="status_bar" type="HFlowContainer" parent="VBoxContainer"]
+layout_direction = 2
+layout_mode = 2
+size_flags_vertical = 2
+
+[node name="error" type="VBoxContainer" parent="VBoxContainer/status_bar"]
+custom_minimum_size = Vector2(0, 48)
+layout_mode = 2
+size_flags_vertical = 0
+theme_override_constants/separation = -2
+
+[node name="icon" type="HBoxContainer" parent="VBoxContainer/status_bar/error"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="icon_errors" type="TextureRect" parent="VBoxContainer/status_bar/error/icon"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Error Tests"
+texture = SubResource("ImageTexture_ivm1h")
+stretch_mode = 3
+
+[node name="btn_up" type="Button" parent="VBoxContainer/status_bar/error/icon"]
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Jump to the previous error test"
+icon = SubResource("ImageTexture_1oriu")
+
+[node name="counter" type="HBoxContainer" parent="VBoxContainer/status_bar/error"]
+auto_translate_mode = 2
+layout_mode = 2
+localize_numeral_system = false
+
+[node name="error_value" type="Label" parent="VBoxContainer/status_bar/error/counter"]
+unique_name_in_owner = true
+use_parent_material = true
+custom_minimum_size = Vector2(32, 0)
+layout_mode = 2
+size_flags_horizontal = 10
+text = "0"
+horizontal_alignment = 2
+justification_flags = 0
+visible_characters = 3
+visible_ratio = 3.0
+
+[node name="btn_down" type="Button" parent="VBoxContainer/status_bar/error/counter"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+tooltip_text = "Jump to the next error test"
+icon = SubResource("ImageTexture_ikyhk")
+
+[node name="VSeparator" type="VSeparator" parent="VBoxContainer/status_bar"]
+layout_mode = 2
+
+[node name="failure" type="VBoxContainer" parent="VBoxContainer/status_bar"]
+custom_minimum_size = Vector2(0, 48)
+layout_mode = 2
+theme_override_constants/separation = -2
+
+[node name="icon" type="HBoxContainer" parent="VBoxContainer/status_bar/failure"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="icon_failures" type="TextureRect" parent="VBoxContainer/status_bar/failure/icon"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Failed Tests"
+texture = SubResource("ImageTexture_suo5c")
+stretch_mode = 3
+
+[node name="btn_up" type="Button" parent="VBoxContainer/status_bar/failure/icon"]
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Jump to the previous failed test"
+icon = SubResource("ImageTexture_1oriu")
+
+[node name="counter" type="HBoxContainer" parent="VBoxContainer/status_bar/failure"]
+auto_translate_mode = 2
+layout_mode = 2
+localize_numeral_system = false
+
+[node name="failure_value" type="Label" parent="VBoxContainer/status_bar/failure/counter"]
+unique_name_in_owner = true
+use_parent_material = true
+custom_minimum_size = Vector2(32, 0)
+layout_mode = 2
+size_flags_horizontal = 10
+text = "0"
+horizontal_alignment = 2
+justification_flags = 0
+visible_characters = 3
+visible_ratio = 3.0
+
+[node name="btn_down" type="Button" parent="VBoxContainer/status_bar/failure/counter"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+tooltip_text = "Jump to the next failed test"
+icon = SubResource("ImageTexture_ikyhk")
+
+[node name="VSeparator2" type="VSeparator" parent="VBoxContainer/status_bar"]
+layout_mode = 2
+
+[node name="flaky" type="VBoxContainer" parent="VBoxContainer/status_bar"]
+custom_minimum_size = Vector2(0, 48)
+layout_mode = 2
+theme_override_constants/separation = -2
+
+[node name="icon" type="HBoxContainer" parent="VBoxContainer/status_bar/flaky"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="icon_flaky" type="TextureRect" parent="VBoxContainer/status_bar/flaky/icon"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Flaky Tests"
+texture = SubResource("ImageTexture_d5kq4")
+stretch_mode = 3
+
+[node name="btn_up" type="Button" parent="VBoxContainer/status_bar/flaky/icon"]
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Jump to the previous flaky test"
+icon = SubResource("ImageTexture_1oriu")
+
+[node name="counter" type="HBoxContainer" parent="VBoxContainer/status_bar/flaky"]
+auto_translate_mode = 2
+layout_mode = 2
+localize_numeral_system = false
+
+[node name="flaky_value" type="Label" parent="VBoxContainer/status_bar/flaky/counter"]
+unique_name_in_owner = true
+use_parent_material = true
+custom_minimum_size = Vector2(32, 0)
+layout_mode = 2
+size_flags_horizontal = 10
+text = "0"
+horizontal_alignment = 2
+justification_flags = 0
+visible_characters = 3
+visible_ratio = 3.0
+
+[node name="btn_down" type="Button" parent="VBoxContainer/status_bar/flaky/counter"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+tooltip_text = "Jump to the next flaky test"
+icon = SubResource("ImageTexture_ikyhk")
+
+[node name="VSeparator3" type="VSeparator" parent="VBoxContainer/status_bar"]
+layout_mode = 2
+
+[node name="skipped" type="VBoxContainer" parent="VBoxContainer/status_bar"]
+custom_minimum_size = Vector2(0, 48)
+layout_mode = 2
+theme_override_constants/separation = -2
+
+[node name="icon" type="HBoxContainer" parent="VBoxContainer/status_bar/skipped"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="icon_skipped" type="TextureRect" parent="VBoxContainer/status_bar/skipped/icon"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Skipped Tests"
+texture = SubResource("ImageTexture_qagbu")
+stretch_mode = 3
+
+[node name="btn_up" type="Button" parent="VBoxContainer/status_bar/skipped/icon"]
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Jump to the previous skipped test"
+icon = SubResource("ImageTexture_1oriu")
+
+[node name="counter" type="HBoxContainer" parent="VBoxContainer/status_bar/skipped"]
+auto_translate_mode = 2
+layout_mode = 2
+localize_numeral_system = false
+
+[node name="skipped_value" type="Label" parent="VBoxContainer/status_bar/skipped/counter"]
+unique_name_in_owner = true
+use_parent_material = true
+custom_minimum_size = Vector2(32, 0)
+layout_mode = 2
+size_flags_horizontal = 10
+text = "0"
+horizontal_alignment = 2
+justification_flags = 0
+visible_characters = 3
+visible_ratio = 3.0
+
+[node name="btn_down" type="Button" parent="VBoxContainer/status_bar/skipped/counter"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+tooltip_text = "Jump to the next skipped test"
+icon = SubResource("ImageTexture_ikyhk")
+
+[connection signal="pressed" from="VBoxContainer/tree_tools/tree_buttons/btn_tree_sync" to="." method="_on_btn_tree_sync_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/error/icon/btn_up" to="." method="_on_btn_error_up_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/error/counter/btn_down" to="." method="_on_btn_error_down_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/failure/icon/btn_up" to="." method="_on_failure_up_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/failure/counter/btn_down" to="." method="_on_failure_down_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/flaky/icon/btn_up" to="." method="_on_btn_flaky_up_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/flaky/counter/btn_down" to="." method="_on_btn_flaky_down_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/skipped/icon/btn_up" to="." method="_on_btn_skipped_up_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/skipped/counter/btn_down" to="." method="_on_btn_skipped_down_pressed"]
diff --git a/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd b/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd
new file mode 100644
index 0000000..4a92c52
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd
@@ -0,0 +1,104 @@
+@tool
+extends PanelContainer
+
+
+const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.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: InspectorTreeMainPanel
+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/InspectorToolBar.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd.uid
new file mode 100644
index 0000000..69c8748
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd.uid
@@ -0,0 +1 @@
+uid://bepy3y6bvaume
diff --git a/addons/gdUnit4/src/ui/parts/InspectorToolBar.tscn b/addons/gdUnit4/src/ui/parts/InspectorToolBar.tscn
new file mode 100644
index 0000000..8cd529e
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorToolBar.tscn
@@ -0,0 +1,202 @@
+[gd_scene load_steps=17 format=3 uid="uid://dx7xy4dgi3wwb"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/InspectorToolBar.gd" id="3"]
+
+[sub_resource type="DPITexture" id="DPITexture_c7rhl"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M6.973 7.441a4 4 0 1 0-1.414-1.414L3.293 8.293l1.414 1.414zM9 2a2 2 0 0 1 0 4 2 2 0 0 1 0-4zm-9 8v6h2a3 3 0 1 0 0-6zm5 3a3 3 0 1 0 6 0 3 3 0 0 0-6 0zm6 0a3 3 0 0 0 3 3h1v-2h-1a1 1 0 0 1 0-2h1v-2h-1a3 3 0 0 0-3 3zm-9-1a1 1 0 0 1 0 2zm6 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z\"/></svg>
+"
+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(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_3erui"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M4 1 3 3l1 2v4H2v3.5a2.5 2.5 0 0 0 5 0V9H5V5l1-2-1-2zm6 .174a3 3 0 0 0 0 5.652V14a1 1 0 0 0 2 0V6.824a3 3 0 0 0 0-5.648V4a1 1 0 0 1-2 0z\"/></svg>
+"
+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(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="InputEventKey" id="InputEventKey_p22nw"]
+alt_pressed = true
+pressed = true
+keycode = 4194338
+physical_keycode = 4194338
+
+[sub_resource type="Shortcut" id="Shortcut_3lcek"]
+events = [SubResource("InputEventKey_p22nw")]
+
+[sub_resource type="Image" id="Image_ndw0i"]
+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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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, 195, 224, 224, 224, 210, 224, 224, 224, 56, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 195, 224, 224, 224, 210, 224, 224, 224, 56, 0, 0, 0, 0, 0, 0, 0, 0, 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, 253, 224, 224, 224, 139, 224, 224, 224, 8, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 253, 224, 224, 224, 139, 224, 224, 224, 8, 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, 225, 225, 225, 215, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 225, 225, 225, 215, 224, 224, 224, 56, 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, 253, 224, 224, 224, 139, 224, 224, 224, 8, 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, 225, 225, 225, 183, 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, 225, 225, 225, 182, 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, 252, 225, 225, 225, 134, 255, 255, 255, 6, 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, 212, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 212, 226, 226, 226, 52, 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, 252, 225, 225, 225, 134, 255, 255, 255, 6, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 252, 225, 225, 225, 134, 255, 255, 255, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 225, 225, 225, 191, 224, 224, 224, 206, 226, 226, 226, 52, 0, 0, 0, 0, 0, 0, 0, 0, 225, 225, 225, 191, 224, 224, 224, 206, 226, 226, 226, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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_eoihf"]
+image = SubResource("Image_ndw0i")
+
+[sub_resource type="InputEventKey" id="InputEventKey_6gwbs"]
+alt_pressed = true
+pressed = true
+keycode = 4194336
+physical_keycode = 4194336
+
+[sub_resource type="Shortcut" id="Shortcut_2i8uq"]
+events = [SubResource("InputEventKey_6gwbs")]
+
+[sub_resource type="DPITexture" id="DPITexture_87jj1"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M4 12a1 1 0 0 0 1.555.832l6-4a1 1 0 0 0 0-1.664l-6-4A1 1 0 0 0 4 4z\"/></svg>
+"
+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(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="InputEventKey" id="InputEventKey_fewwl"]
+alt_pressed = true
+pressed = true
+keycode = 4194337
+physical_keycode = 4194337
+
+[sub_resource type="Shortcut" id="Shortcut_f3lkx"]
+events = [SubResource("InputEventKey_fewwl")]
+
+[sub_resource type="DPITexture" id="DPITexture_7oobd"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M3 3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm4.975 0A1 1 0 0 0 7 3.998v8a1 1 0 0 0 1.625.781l5-4a1 1 0 0 0 0-1.56l-5-4A1 1 0 0 0 7.975 3z\"/></svg>
+"
+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(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="InputEventKey" id="InputEventKey_lvbnb"]
+alt_pressed = true
+pressed = true
+keycode = 4194339
+physical_keycode = 4194339
+
+[sub_resource type="Shortcut" id="Shortcut_6idxu"]
+events = [SubResource("InputEventKey_lvbnb")]
+
+[sub_resource type="DPITexture" id="DPITexture_qf2s1"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><rect width=\"10\" height=\"10\" x=\"3\" y=\"3\" fill=\"#e0e0e0\" rx=\"1\"/></svg>
+"
+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(1, 0.87, 0.4, 1)
+}
+
+[node name="ToolBar" type="PanelContainer"]
+anchors_preset = 10
+anchor_right = 1.0
+offset_right = -894.0
+offset_bottom = 24.0
+grow_horizontal = 2
+size_flags_horizontal = 3
+size_flags_vertical = 4
+script = ExtResource("3")
+
+[node name="HBoxContainer" type="HBoxContainer" parent="."]
+layout_mode = 2
+
+[node name="tools" type="HBoxContainer" parent="HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 0
+size_flags_vertical = 4
+
+[node name="help" type="Button" parent="HBoxContainer/tools"]
+unique_name_in_owner = true
+layout_mode = 2
+icon = SubResource("DPITexture_c7rhl")
+
+[node name="tool" type="Button" parent="HBoxContainer/tools"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "GdUnit Settings"
+icon = SubResource("DPITexture_3erui")
+
+[node name="controls" type="HBoxContainer" parent="HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 6
+size_flags_vertical = 4
+alignment = 1
+
+[node name="VSeparator3" type="VSeparator" parent="HBoxContainer/controls"]
+layout_mode = 2
+
+[node name="run_overall" type="Button" parent="HBoxContainer/controls"]
+unique_name_in_owner = true
+use_parent_material = true
+layout_mode = 2
+tooltip_text = "Run overall tests"
+shortcut = SubResource("Shortcut_3lcek")
+icon = SubResource("ImageTexture_eoihf")
+metadata/GdUnitCommand = "Run Tests Overall"
+
+[node name="run" type="Button" parent="HBoxContainer/controls"]
+unique_name_in_owner = true
+use_parent_material = true
+layout_mode = 2
+tooltip_text = "Rerun unit tests"
+disabled = true
+shortcut = SubResource("Shortcut_2i8uq")
+icon = SubResource("DPITexture_87jj1")
+metadata/GdUnitCommand = "Run Inspector Tests"
+
+[node name="debug" type="Button" parent="HBoxContainer/controls"]
+unique_name_in_owner = true
+use_parent_material = true
+layout_mode = 2
+tooltip_text = "Rerun unit tests (Debug)"
+disabled = true
+shortcut = SubResource("Shortcut_f3lkx")
+icon = SubResource("DPITexture_7oobd")
+metadata/GdUnitCommand = "Debug Inspector Tests"
+
+[node name="stop" type="Button" parent="HBoxContainer/controls"]
+unique_name_in_owner = true
+use_parent_material = true
+layout_mode = 2
+tooltip_text = "Stops runing unit tests"
+disabled = true
+shortcut = SubResource("Shortcut_6idxu")
+icon = SubResource("DPITexture_qf2s1")
+metadata/GdUnitCommand = "Stop Test Session"
+
+[node name="VSeparator4" type="VSeparator" parent="HBoxContainer/controls"]
+layout_mode = 2
+
+[node name="CenterContainer" type="HBoxContainer" parent="HBoxContainer"]
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+alignment = 2
+
+[node name="version" type="Label" parent="HBoxContainer/CenterContainer"]
+unique_name_in_owner = true
+auto_translate_mode = 2
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 13
+localize_numeral_system = false
+text = "gdUnit4 6.1.0"
+horizontal_alignment = 1
+justification_flags = 160
+
+[connection signal="pressed" from="HBoxContainer/tools/help" to="." method="_on_wiki_pressed"]
+[connection signal="pressed" from="HBoxContainer/tools/tool" to="." method="_on_btn_tool_pressed"]
+[connection signal="pressed" from="HBoxContainer/controls/run_overall" to="." method="_on_button_pressed" flags=18]
+[connection signal="pressed" from="HBoxContainer/controls/run" to="." method="_on_button_pressed" flags=18]
+[connection signal="pressed" from="HBoxContainer/controls/debug" to="." method="_on_button_pressed" flags=18]
+[connection signal="pressed" from="HBoxContainer/controls/stop" to="." method="_on_button_pressed" flags=18]
diff --git a/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd
new file mode 100644
index 0000000..f55dbee
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd
@@ -0,0 +1,1210 @@
+@tool
+extends VSplitContainer
+
+## 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 = $Panel/Tree
+@onready var _report_list: Node = $report/ScrollContainer/list
+@onready var _report_template: RichTextLabel = $report/report_template
+@onready var _context_menu: GdUnitInspectorContextMenu = $contextMenu
+@onready var _discover_hint: Control = %discover_hint
+@onready var _spinner: Button = %spinner
+
+# loading tree icons
+@onready var ICON_SPINNER := GdUnitUiTools.get_spinner()
+@onready var ICON_FOLDER := GdUnitUiTools.get_icon("Folder")
+# gdscript icons
+@onready var ICON_GDSCRIPT_TEST_DEFAULT := GdUnitUiTools.get_icon("GDScript", Color.LIGHT_GRAY)
+@onready var ICON_GDSCRIPT_TEST_SUCCESS := GdUnitUiTools.get_GDScript_icon("StatusSuccess", Color.DARK_GREEN)
+@onready var ICON_GDSCRIPT_TEST_FLAKY := GdUnitUiTools.get_GDScript_icon("CheckBox", Color.GREEN_YELLOW)
+@onready var ICON_GDSCRIPT_TEST_FAILED := GdUnitUiTools.get_GDScript_icon("StatusError", Color.SKY_BLUE)
+@onready var ICON_GDSCRIPT_TEST_ERROR := GdUnitUiTools.get_GDScript_icon("StatusError", Color.DARK_RED)
+@onready var ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN := GdUnitUiTools.get_GDScript_icon("Unlinked", Color.DARK_GREEN)
+@onready var ICON_GDSCRIPT_TEST_FAILED_ORPHAN := GdUnitUiTools.get_GDScript_icon("Unlinked", Color.SKY_BLUE)
+@onready var ICON_GDSCRIPT_TEST_ERRORS_ORPHAN := GdUnitUiTools.get_GDScript_icon("Unlinked", Color.DARK_RED)
+# csharp script icons
+@onready var ICON_CSSCRIPT_TEST_DEFAULT := GdUnitUiTools.get_icon("CSharpScript", Color.LIGHT_GRAY)
+@onready var ICON_CSSCRIPT_TEST_SUCCESS := GdUnitUiTools.get_CSharpScript_icon("StatusSuccess", Color.DARK_GREEN)
+@onready var ICON_CSSCRIPT_TEST_FAILED := GdUnitUiTools.get_CSharpScript_icon("StatusError", Color.SKY_BLUE)
+@onready var ICON_CSSCRIPT_TEST_ERROR := GdUnitUiTools.get_CSharpScript_icon("StatusError", Color.DARK_RED)
+@onready var ICON_CSSCRIPT_TEST_SUCCESS_ORPHAN := GdUnitUiTools.get_CSharpScript_icon("Unlinked", Color.DARK_GREEN)
+@onready var ICON_CSSCRIPT_TEST_FAILED_ORPHAN := GdUnitUiTools.get_CSharpScript_icon("Unlinked", Color.SKY_BLUE)
+@onready var ICON_CSSCRIPT_TEST_ERRORS_ORPHAN := GdUnitUiTools.get_CSharpScript_icon("Unlinked", Color.DARK_RED)
+
+
+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 is_test_case(item) and (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 is_test_case(next) and 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 item.has_meta(META_GDUNIT_STATE) and item.get_meta(META_GDUNIT_STATE) == 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_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)
+
+ _spinner.icon = GdUnitUiTools.get_spinner()
+ 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()
+
+
+# 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_STATE, STATE.INITIAL)
+ _tree_root.set_meta(META_GDUNIT_SUCCESS_TESTS, 0)
+ # 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:
+ clear_reports()
+ 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)
+ _tree_root.set_meta(META_GDUNIT_STATE, 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_custom_color(0, Color.LIGHT_GRAY)
+ item.set_tooltip_text(0, "")
+ item.set_text_overrun_behavior(0, TextServer.OVERRUN_TRIM_CHAR)
+ item.set_expand_right(0, true)
+
+ item.set_custom_color(1, Color.LIGHT_GRAY)
+ item.set_text(1, "")
+ item.set_expand_right(1, true)
+ item.set_tooltip_text(1, "")
+
+ item.set_meta(META_GDUNIT_STATE, STATE.INITIAL)
+ 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_icon_by_state(item)
+
+
+func set_state_running(item: TreeItem, is_running: bool) -> void:
+ if is_state_running(item):
+ return
+ if is_item_state(item, STATE.INITIAL):
+ item.set_custom_color(0, Color.DARK_GREEN)
+ item.set_custom_color(1, Color.DARK_GREEN)
+ item.set_meta(META_GDUNIT_STATE, STATE.RUNNING)
+ item.collapsed = false
+
+ if is_running:
+ item.set_icon(0, ICON_SPINNER)
+ else:
+ set_item_icon_by_state(item)
+ for child in item.get_children():
+ set_item_icon_by_state(child)
+
+ var parent := item.get_parent()
+ if parent != _tree_root:
+ set_state_running(parent, is_running)
+
+
+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.set_custom_color(0, Color.GREEN)
+ item.set_custom_color(1, Color.GREEN)
+ item.set_meta(META_GDUNIT_STATE, STATE.SUCCESS)
+ item.collapsed = GdUnitSettings.is_inspector_node_collapse()
+ set_item_icon_by_state(item)
+
+
+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)
+ item.set_meta(META_GDUNIT_STATE, STATE.FLAKY)
+ 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.set_custom_color(0, Color.GREEN_YELLOW)
+ item.set_custom_color(1, Color.GREEN_YELLOW)
+ item.collapsed = false
+ set_item_icon_by_state(item)
+
+
+func set_state_skipped(item: TreeItem) -> void:
+ item.set_meta(META_GDUNIT_STATE, STATE.SKIPPED)
+ item.set_text(1, "(skipped)")
+ item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT)
+ item.set_custom_color(0, Color.DARK_GRAY)
+ item.set_custom_color(1, Color.DARK_GRAY)
+ item.collapsed = false
+ set_item_icon_by_state(item)
+
+
+func set_state_warnings(item: TreeItem) -> void:
+ # Do not overwrite higher states
+ if is_state_error(item) or is_state_failed(item):
+ return
+ item.set_meta(META_GDUNIT_STATE, STATE.WARNING)
+ item.set_custom_color(0, Color.YELLOW)
+ item.set_custom_color(1, Color.YELLOW)
+ item.collapsed = false
+ set_item_icon_by_state(item)
+
+
+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.set_meta(META_GDUNIT_STATE, STATE.FAILED)
+ item.set_custom_color(0, Color.LIGHT_BLUE)
+ item.set_custom_color(1, Color.LIGHT_BLUE)
+ item.collapsed = false
+ set_item_icon_by_state(item)
+
+
+func set_state_error(item: TreeItem) -> void:
+ item.set_meta(META_GDUNIT_STATE, STATE.ERROR)
+ item.set_custom_color(0, Color.ORANGE_RED)
+ item.set_custom_color(1, Color.ORANGE_RED)
+ set_item_icon_by_state(item)
+ item.collapsed = false
+
+
+func set_state_aborted(item: TreeItem) -> void:
+ item.set_meta(META_GDUNIT_STATE, STATE.ABORDED)
+ item.set_custom_color(0, Color.ORANGE_RED)
+ item.set_custom_color(1, Color.ORANGE_RED)
+ item.clear_custom_bg_color(0)
+ item.set_text(1, "(aborted)")
+ item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT)
+ set_item_icon_by_state(item)
+ 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
+ if item.has_meta(META_GDUNIT_ORPHAN):
+ orphan_count += item.get_meta(META_GDUNIT_ORPHAN)
+ item.set_meta(META_GDUNIT_ORPHAN, orphan_count)
+ if item.get_meta(META_GDUNIT_STATE) != STATE.FAILED:
+ item.set_custom_color(0, Color.YELLOW)
+ item.set_custom_color(1, Color.YELLOW)
+ item.set_tooltip_text(0, "Total <%d> orphan nodes detected." % orphan_count)
+ set_item_icon_by_state(item)
+
+
+func update_state(item: TreeItem, event: GdUnitEvent, add_reports := true) -> 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)
+ if add_reports:
+ for report in event.reports():
+ add_report(item, report)
+ set_state_orphan(item, event)
+
+ var parent := item.get_parent()
+ if parent == null:
+ return
+
+ var item_state: int = item.get_meta(META_GDUNIT_STATE)
+ var parent_state: int = parent.get_meta(META_GDUNIT_STATE)
+ if item_state <= parent_state:
+ return
+ update_state(item.get_parent(), event, false)
+
+
+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 abort_running(items:=_tree_root.get_children()) -> void:
+ for item in items:
+ if item.get_icon(0) == ICON_SPINNER:
+ set_state_aborted(item)
+ 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 clear_reports() -> void:
+ for child in _report_list.get_children():
+ _report_list.remove_child(child)
+ child.queue_free()
+
+
+func show_failed_report(selected_item: TreeItem) -> void:
+ clear_reports()
+ if selected_item == null or not selected_item.has_meta(META_GDUNIT_REPORT):
+ return
+ # add new reports
+ for report in get_item_reports(selected_item):
+ var reportNode: RichTextLabel = _report_template.duplicate()
+ _report_list.add_child(reportNode)
+ reportNode.push_color(Color.DARK_TURQUOISE)
+ reportNode.append_text(report.to_string())
+ reportNode.pop()
+ reportNode.visible = true
+
+
+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
+ if event.type() == GdUnitEvent.TESTSUITE_AFTER:
+ update_item_elapsed_time_counter(item, event.elapsed_time())
+ update_state(item, event)
+ set_state_running(item, false)
+
+
+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, true)
+ # force scrolling to current test case
+ _tree.scroll_to_item(item, true)
+ return
+
+ if event.type() == GdUnitEvent.TESTCASE_AFTER:
+ update_item_elapsed_time_counter(item, event.elapsed_time())
+ if event.is_success() or event.is_warning():
+ update_item_processed_counter(item)
+ update_state(item, event)
+ update_progress_counters(item)
+
+
+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
+
+
+func set_item_icon_by_state(item :TreeItem) -> void:
+ if item == _tree_root:
+ return
+ var state :STATE = item.get_meta(META_GDUNIT_STATE)
+ var is_orphan := is_item_state_orphan(item)
+ var resource_path := get_item_source_file(item)
+ item.set_icon(0, get_icon_by_file_type(resource_path, state, is_orphan))
+ if item.get_meta(META_GDUNIT_TYPE) == GdUnitType.FOLDER:
+ item.set_icon_modulate(0, Color.SKY_BLUE)
+
+
+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: STATE = item.get_meta(META_GDUNIT_STATE)
+ 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(item: TreeItem, time: int) -> void:
+ item.set_text(1, "%s" % LocalTime.elapsed(time))
+ item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT)
+ item.set_meta(META_GDUNIT_EXECUTION_TIME, time)
+
+ var parent := item.get_parent()
+ if parent == _tree_root:
+ return
+ var elapsed_time :int = parent.get_meta(META_GDUNIT_EXECUTION_TIME) + time
+ var type :GdUnitType = item.get_meta(META_GDUNIT_TYPE)
+ match type:
+ GdUnitType.TEST_CASE:
+ return
+ GdUnitType.TEST_SUITE:
+ update_item_elapsed_time_counter(parent, elapsed_time)
+ #GdUnitType.FOLDER:
+ # update_item_elapsed_time_counter(parent, elapsed_time)
+
+
+func get_icon_by_file_type(path: String, state: STATE, orphans: bool) -> Texture2D:
+ if path.get_extension() == "gd":
+ match state:
+ STATE.INITIAL:
+ return ICON_GDSCRIPT_TEST_DEFAULT
+ STATE.SUCCESS:
+ return ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_SUCCESS
+ STATE.ERROR:
+ return ICON_GDSCRIPT_TEST_ERRORS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_ERROR
+ STATE.FAILED:
+ return ICON_GDSCRIPT_TEST_FAILED_ORPHAN if orphans else ICON_GDSCRIPT_TEST_FAILED
+ STATE.WARNING:
+ return ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_DEFAULT
+ STATE.FLAKY:
+ return ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_FLAKY
+ _:
+ return ICON_GDSCRIPT_TEST_DEFAULT
+ if path.get_extension() == "cs":
+ match state:
+ STATE.INITIAL:
+ return ICON_CSSCRIPT_TEST_DEFAULT
+ STATE.SUCCESS:
+ return ICON_CSSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_CSSCRIPT_TEST_SUCCESS
+ STATE.ERROR:
+ return ICON_CSSCRIPT_TEST_ERRORS_ORPHAN if orphans else ICON_CSSCRIPT_TEST_ERROR
+ STATE.FAILED:
+ return ICON_CSSCRIPT_TEST_FAILED_ORPHAN if orphans else ICON_CSSCRIPT_TEST_FAILED
+ STATE.WARNING:
+ return ICON_CSSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_CSSCRIPT_TEST_DEFAULT
+ _:
+ return ICON_CSSCRIPT_TEST_DEFAULT
+ match state:
+ STATE.INITIAL:
+ return ICON_FOLDER
+ STATE.ERROR:
+ return ICON_FOLDER
+ STATE.FAILED:
+ return ICON_FOLDER
+ _:
+ 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]:
+ 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)
+ clear_reports()
+
+
+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()
+ show_failed_report(_current_selected_item)
+ 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_BEFORE:
+ update_test_suite(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/InspectorTreeMainPanel.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd.uid
new file mode 100644
index 0000000..587b139
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd.uid
@@ -0,0 +1 @@
+uid://m0w5xmvdst0k
diff --git a/addons/gdUnit4/src/ui/parts/InspectorTreePanel.tscn b/addons/gdUnit4/src/ui/parts/InspectorTreePanel.tscn
new file mode 100644
index 0000000..2754f74
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorTreePanel.tscn
@@ -0,0 +1,240 @@
+[gd_scene format=3 uid="uid://bqfpidewtpeg0"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd" id="1"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.tscn" id="2_o6s0p"]
+
+[sub_resource type="DPITexture" id="DPITexture_miuuy"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M4 12a1 1 0 0 0 1.555.832l6-4a1 1 0 0 0 0-1.664l-6-4A1 1 0 0 0 4 4z\"/></svg>
+"
+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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M3 3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm4.975 0A1 1 0 0 0 7 3.998v8a1 1 0 0 0 1.625.781l5-4a1 1 0 0 0 0-1.56l-5-4A1 1 0 0 0 7.975 3z\"/></svg>
+"
+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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"m8 9.669-3.536 2.583H7v2.537h2v-2.537h2.536zm0-3.314L4.464 3.772H7V1.235h2v2.537h2.536zm-7.296.73h14.591v1.831H.704z\"/></svg>
+"
+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 = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"m8 16-3.5-2.5H7V11h2v2.5h2.5zM8 0 4.5 2.5H7V5h2V2.5h2.5zM1 7h14v2H1z\"/></svg>
+"
+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_8v04w"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path d=\"M9 1v3.055a4 4 0 0 1 1.027.426l2.159-2.159A7 7 0 0 0 9 1z\"/><path fill-opacity=\".2\" d=\"M7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.08zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.815zm11.2 0-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM1.08 9a7 7 0 0 0 1.32 3.186l2.158-2.159A4 4 0 0 1 4.13 9H1.08zm10.786 0a4 4 0 0 1-.425 1.027l2.158 2.159A7 7 0 0 0 14.921 9h-3.055zm-5.894 2.441L3.814 13.6a7 7 0 0 0 3.185 1.322v-3.055a4 4 0 0 1-1.027-.426zm4.055 0a4 4 0 0 1-1.028.428v3.05a7 7 0 0 0 3.186-1.32l-2.158-2.158z\"/></g></svg>
+"
+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_arwmg"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.079zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.814zM1.08 9a7 7 0 0 0 1.32 3.185l2.159-2.158A4 4 0 0 1 4.131 9H1.08zm10.787 0a4 4 0 0 1-.426 1.027l2.159 2.158A7 7 0 0 0 14.922 9h-3.055zm-5.894 2.441L3.814 13.6A7 7 0 0 0 7 14.922v-3.055a4 4 0 0 1-1.027-.426zm4.054 0A4 4 0 0 1 9 11.869v3.05a7 7 0 0 0 3.186-1.32l-2.159-2.158z\"/><path d=\"m13.6 3.8-2.158 2.158a4 4 0 0 1 .428 1.028h3.05A7 7 0 0 0 13.6 3.8z\"/></g></svg>
+"
+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_rqglq"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.079zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.814zm11.2 0-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM1.08 9a7 7 0 0 0 1.32 3.185l2.158-2.158A4 4 0 0 1 4.13 9H1.08zm4.892 2.441L3.814 13.6a7 7 0 0 0 3.185 1.322v-3.055a4 4 0 0 1-1.027-.426zm4.055 0a4 4 0 0 1-1.028.428v3.05a7 7 0 0 0 3.186-1.32l-2.158-2.158z\"/><path d=\"M11.867 9a4 4 0 0 1-.426 1.027l2.158 2.159A7 7 0 0 0 14.922 9h-3.055z\"/></g></svg>
+"
+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_ltb1l"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.079zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.814zm11.2 0-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM1.08 9a7 7 0 0 0 1.32 3.185l2.158-2.158A4 4 0 0 1 4.13 9H1.08zm10.786 0a4 4 0 0 1-.425 1.027l2.158 2.158A7 7 0 0 0 14.921 9h-3.055zm-5.894 2.441L3.814 13.6a7 7 0 0 0 3.185 1.322v-3.055a4 4 0 0 1-1.027-.426z\"/><path d=\"M10.027 11.4A4 4 0 0 1 9 11.828v3.05a7 7 0 0 0 3.185-1.32z\"/></g></svg>
+"
+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_2lq8w"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.079zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.814zm11.2 0-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM1.08 9a7 7 0 0 0 1.32 3.185l2.158-2.158A4 4 0 0 1 4.13 9H1.08zm10.786 0a4 4 0 0 1-.425 1.027l2.158 2.158A7 7 0 0 0 14.921 9h-3.055zm-1.84 2.441a4 4 0 0 1-1.027.428v3.05a7 7 0 0 0 3.186-1.32l-2.158-2.158z\"/><path d=\"m5.973 11.4-2.159 2.158A7 7 0 0 0 7 14.88v-3.054a4 4 0 0 1-1.027-.426z\"/></g></svg>
+"
+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_kwwmp"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.079zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.814zm11.2 0-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM11.867 9a4 4 0 0 1-.426 1.027l2.158 2.158A7 7 0 0 0 14.922 9h-3.055zm-5.895 2.441L3.814 13.6A7 7 0 0 0 7 14.922v-3.055a4 4 0 0 1-1.028-.426zm4.055 0A4 4 0 0 1 9 11.869v3.05a7 7 0 0 0 3.185-1.32l-2.158-2.158z\"/><path d=\"M1.08 9a7 7 0 0 0 1.32 3.186l2.159-2.159A4 4 0 0 1 4.131 9H1.08z\"/></g></svg>
+"
+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_t1rin"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.079zm6.6 2.734-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM1.08 9a7 7 0 0 0 1.32 3.185l2.158-2.158A4 4 0 0 1 4.13 9H1.08zm10.787 0a4 4 0 0 1-.426 1.027l2.158 2.158A7 7 0 0 0 14.921 9h-3.054zm-5.895 2.441L3.814 13.6a7 7 0 0 0 3.185 1.322v-3.055a4 4 0 0 1-1.027-.426zm4.055 0A4 4 0 0 1 9 11.869v3.05a7 7 0 0 0 3.185-1.32l-2.158-2.158z\"/><path d=\"M2.4 3.8a7 7 0 0 0-1.322 3.186h3.055a4 4 0 0 1 .426-1.028L2.4 3.8z\"/></g></svg>
+"
+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_pi1kv"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.814zm11.2 0-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM1.08 9a7 7 0 0 0 1.32 3.185l2.158-2.158A4 4 0 0 1 4.13 9H1.08zm10.786 0a4 4 0 0 1-.425 1.027l2.158 2.158A7 7 0 0 0 14.921 9h-3.055zm-5.894 2.441L3.814 13.6a7 7 0 0 0 3.185 1.322v-3.055a4 4 0 0 1-1.027-.426zm4.055 0a4 4 0 0 1-1.028.428v3.05a7 7 0 0 0 3.186-1.32l-2.158-2.158z\"/><path d=\"M7 1a7 7 0 0 0-3.186 1.32l2.159 2.159A4 4 0 0 1 7 4.05z\"/></g></svg>
+"
+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="AnimatedTexture" id="AnimatedTexture_eor1x"]
+frames = 8
+speed_scale = 2.5
+frame_0/texture = SubResource("DPITexture_8v04w")
+frame_0/duration = 0.2
+frame_1/texture = SubResource("DPITexture_arwmg")
+frame_1/duration = 0.2
+frame_2/texture = SubResource("DPITexture_rqglq")
+frame_2/duration = 0.2
+frame_3/texture = SubResource("DPITexture_ltb1l")
+frame_3/duration = 0.2
+frame_4/texture = SubResource("DPITexture_2lq8w")
+frame_4/duration = 0.2
+frame_5/texture = SubResource("DPITexture_kwwmp")
+frame_5/duration = 0.2
+frame_6/texture = SubResource("DPITexture_t1rin")
+frame_6/duration = 0.2
+frame_7/texture = SubResource("DPITexture_pi1kv")
+frame_7/duration = 0.2
+
+[node name="MainPanel" type="VSplitContainer" unique_id=1044515559]
+use_parent_material = true
+clip_contents = true
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_right = -924.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+split_offsets = PackedInt32Array(200)
+split_offset = 200
+script = ExtResource("1")
+
+[node name="contextMenu" parent="." unique_id=73764904 instance=ExtResource("2_o6s0p")]
+visible = false
+item_0/icon = SubResource("DPITexture_miuuy")
+item_1/icon = SubResource("DPITexture_ern2r")
+item_2/text = "Run Tests Until Fail"
+item_2/icon = SubResource("DPITexture_miuuy")
+item_4/icon = SubResource("DPITexture_qdci2")
+item_5/icon = SubResource("DPITexture_hed0i")
+
+[node name="Panel" type="PanelContainer" parent="." unique_id=641912870]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="Tree" type="Tree" parent="Panel" unique_id=741772884]
+use_parent_material = true
+layout_mode = 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="Panel" unique_id=555683311]
+unique_name_in_owner = true
+visible = false
+use_parent_material = true
+layout_mode = 2
+alignment = 1
+
+[node name="spinner" type="Button" parent="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"
+icon = SubResource("AnimatedTexture_eor1x")
+flat = true
+alignment = 2
+
+[node name="report" type="PanelContainer" parent="." unique_id=503703488]
+clip_contents = true
+layout_mode = 2
+size_flags_horizontal = 11
+size_flags_vertical = 11
+
+[node name="report_template" type="RichTextLabel" parent="report" unique_id=817912164]
+auto_translate_mode = 2
+use_parent_material = true
+clip_contents = false
+layout_mode = 2
+size_flags_horizontal = 3
+localize_numeral_system = false
+focus_mode = 2
+bbcode_enabled = true
+fit_content = true
+selection_enabled = true
+
+[node name="ScrollContainer" type="ScrollContainer" parent="report" unique_id=1479075289]
+use_parent_material = true
+custom_minimum_size = Vector2(0, 80)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 11
+
+[node name="list" type="VBoxContainer" parent="report/ScrollContainer" unique_id=1104634565]
+clip_contents = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[connection signal="item_activated" from="Panel/Tree" to="." method="_on_Tree_item_activated"]
+[connection signal="item_mouse_selected" from="Panel/Tree" to="." method="_on_tree_item_mouse_selected"]
+[connection signal="item_selected" from="Panel/Tree" to="." method="_on_Tree_item_selected"]
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..4b9116e
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd.uid
@@ -0,0 +1 @@
+uid://b6rgevbcd0k5n
diff --git a/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn
new file mode 100644
index 0000000..e656bf8
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn
@@ -0,0 +1,36 @@
+[gd_scene 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..d92d2e4
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd.uid
@@ -0,0 +1 @@
+uid://eqhu8casglok
diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn
new file mode 100644
index 0000000..08afee4
--- /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/TestSuiteTemplate.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..3b01f34
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd
@@ -0,0 +1,255 @@
+@tool
+extends ScrollContainer
+
+
+@onready var _hooks_tree: Tree = %hooks_tree
+@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_hook_item: TreeItem = null
+var _root: TreeItem
+var _system_box_style: StyleBoxFlat
+var _priority_box_style: StyleBoxFlat
+
+func _ready() -> void:
+ _setup_styles()
+ _setup_buttons()
+ _setup_tree()
+ _load_registered_hooks()
+
+
+func _setup_styles() -> void:
+ _system_box_style = StyleBoxFlat.new()
+ _system_box_style.bg_color = Color(1.0, 0.76, 0.03, 1)
+ _system_box_style.corner_radius_top_left = 6
+ _system_box_style.corner_radius_top_right = 6
+ _system_box_style.corner_radius_bottom_left = 6
+ _system_box_style.corner_radius_bottom_right = 6
+ _priority_box_style = _system_box_style.duplicate()
+ _priority_box_style.bg_color = Color(0.26, 0.54, 0.89, 1)
+
+
+func _setup_buttons() -> void:
+ #if Engine.is_editor_hint():
+ # _btn_move_up.icon = GdUnitUiTools.get_icon("MoveUp")
+ # _btn_move_down.icon = GdUnitUiTools.get_icon("MoveDown")
+ # _btn_add.icon = GdUnitUiTools.get_icon("Add")
+ # _btn_delete.icon = GdUnitUiTools.get_icon("Remove")
+ pass
+
+
+func _setup_tree() -> void:
+ _hooks_tree.clear()
+ _root = _hooks_tree.create_item()
+ _hooks_tree.set_columns(2)
+ _hooks_tree.set_column_custom_minimum_width(1, 32)
+ _hooks_tree.set_column_expand(1, false)
+ _hooks_tree.set_hide_root(true)
+ _hooks_tree.set_hide_folding(true)
+ _hooks_tree.set_select_mode(Tree.SELECT_SINGLE)
+ _hooks_tree.item_selected.connect(_on_hook_selected)
+ _hooks_tree.item_edited.connect(_on_item_edited)
+
+
+func _load_registered_hooks() -> void:
+ var hook_service := GdUnitTestSessionHookService.instance()
+ for hook: GdUnitTestSessionHook in hook_service.enigne_hooks:
+ _create_hook_tree_item(hook)
+
+ # Select first item if any
+ if _root.get_child_count() > 0:
+ var first_item: TreeItem = _root.get_first_child()
+ first_item.select(0)
+ _on_hook_selected()
+
+
+func _create_hook_tree_item(hook: GdUnitTestSessionHook) -> TreeItem:
+ var item: TreeItem = _hooks_tree.create_item(_root)
+ item.set_custom_minimum_height(26)
+ # Column 0: Hook info with custom drawing
+ item.set_cell_mode(0, TreeItem.CELL_MODE_CUSTOM)
+ item.set_custom_draw_callback(0, _draw_hook_item)
+ item.set_editable(0, false)
+ item.set_metadata(0, hook)
+ # Column 1: Checkbox for enable/disable
+ item.set_cell_mode(1, TreeItem.CELL_MODE_CHECK)
+ item.set_checked(1, GdUnitTestSessionHookService.is_enabled(hook))
+ item.set_editable(1, true)
+ item.set_custom_bg_color(1, _hook_bg_color(hook))
+ item.set_tooltip_text(1, "Enable/Disable the Hook")
+ item.propagate_check(1)
+
+ if _is_system_hook(hook):
+ item.set_tooltip_text(0, "System hook - (Read-only)")
+ else:
+ item.set_tooltip_text(0, "User hook")
+ return item
+
+
+func _hook_bg_color(hook: GdUnitTestSessionHook) -> Color:
+ if _is_system_hook(hook):
+ return Color(0.133, 0.118, 0.090, 1) # Brownish background for system hooks
+ return Color(0.176, 0.196, 0.235, 1) # Dark background #2d3142
+
+
+func _draw_hook_item(item: TreeItem, rect: Rect2) -> void:
+ var hook := _get_hook(item)
+ var is_system := _is_system_hook(hook)
+ var is_selected := item == _selected_hook_item
+
+ # Draw background
+ var bg_color := _hook_bg_color(hook) # Dark background #2d3142
+ if is_selected:
+ bg_color = bg_color.lerp(Color(0.2, 0.4, 0.6, 0.3), 0.5) # Blue tint for selection
+ _hooks_tree.draw_rect(rect, bg_color)
+
+ # Draw left border for system hooks
+ if is_system:
+ var border_rect := Rect2(rect.position.x, rect.position.y, 3, rect.size.y)
+ _hooks_tree.draw_rect(border_rect, Color(1.0, 0.76, 0.03, 1)) # Yellow border
+
+ var font := _hooks_tree.get_theme_default_font()
+
+ # Draw hook name
+ var hook_name := hook.name
+ var text_pos := Vector2(rect.position.x + ( 15 if is_system else 12), rect.position.y + 18)
+ var text_color := Color(0.95, 0.95, 0.95, 1)
+ _hooks_tree.draw_string(font, text_pos, hook_name, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, text_color)
+
+ # Draw system badge if needed
+ if is_system:
+ var badge_x := rect.position.x + rect.end.x - 100
+ var badge_y := rect.position.y + 14
+ var system_badge_rect := Rect2(badge_x, badge_y-8, 48, 16)
+ _hooks_tree.draw_style_box(_system_box_style, system_badge_rect)
+
+ var system_text_pos := Vector2(badge_x + 4, badge_y + 4)
+ var system_font_size := 10
+ _hooks_tree.draw_string(font, system_text_pos, "SYSTEM", HORIZONTAL_ALIGNMENT_CENTER, -1, system_font_size, Color(0.1, 0.1, 0.1, 1))
+
+
+func _create_hook_display_text(hook_name: String, priority: int, is_system: bool) -> String:
+ var text := hook_name + "\n"
+ text += "Priority: [color=#4299e1][bgcolor=#4299e1] " + str(priority) + " [/bgcolor][/color]"
+
+ if is_system:
+ text += " [color=#1a202c][bgcolor=#ffc107] SYSTEM [/bgcolor][/color]"
+
+ return text
+
+
+func _update_hook_description() -> void:
+ if _selected_hook_item == null:
+ _hook_description.text = "[i]Select a hook to view its description[/i]"
+ return
+ _hook_description.text = _get_hook(_selected_hook_item).description
+
+
+func _update_hook_buttons() -> void:
+ # Is nothing selected disable the move and delete buttons
+ if _selected_hook_item == null:
+ _btn_move_up.disabled = true
+ _btn_move_down.disabled = true
+ _btn_delete.disabled = true
+ return
+
+ var hook := _get_hook(_selected_hook_item)
+ var is_system := _is_system_hook(hook)
+
+ # Disable the move and delete buttons for system hooks by default
+ if is_system:
+ _btn_move_up.disabled = true
+ _btn_move_down.disabled = true
+ _btn_delete.disabled = true
+ return
+
+ var prev_item: TreeItem = _selected_hook_item.get_prev()
+ var next_item: TreeItem = _selected_hook_item.get_next()
+
+ if prev_item != null:
+ var prev_hook := _get_hook(prev_item)
+ _btn_move_up.disabled = _is_system_hook(prev_hook)
+
+ _btn_move_down.disabled = next_item == null
+ _btn_delete.disabled = false
+
+
+static func _get_hook(item: TreeItem) -> GdUnitTestSessionHook:
+ return item.get_metadata(0)
+
+
+static func _is_system_hook(hook: GdUnitTestSessionHook) -> bool:
+ if hook == null:
+ return false
+ return hook.get_meta("SYSTEM_HOOK")
+
+
+func _on_hook_selected() -> void:
+ _selected_hook_item = _hooks_tree.get_selected()
+ _update_hook_buttons()
+ _update_hook_description()
+
+
+func _on_item_edited() -> void:
+ var selected_hook_item := _hooks_tree.get_selected()
+ if selected_hook_item != null:
+ var hook := _get_hook(selected_hook_item)
+ var is_enabled := selected_hook_item.is_checked(1)
+ GdUnitTestSessionHookService.instance().enable_hook(hook, is_enabled)
+
+
+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 hook_added := _create_hook_tree_item(hook)
+ _hooks_tree.set_selected(hook_added, 0)
+
+
+func _on_btn_delete_hook_pressed() -> void:
+ if _selected_hook_item != null:
+ _root.remove_child(_selected_hook_item)
+ GdUnitTestSessionHookService.instance()\
+ .unregister(_get_hook(_selected_hook_item))
+ _selected_hook_item = null
+ _update_hook_buttons()
+
+
+func _on_btn_move_up_pressed() -> void:
+ var prev := _selected_hook_item.get_prev()
+ _selected_hook_item.move_before(prev)
+ GdUnitTestSessionHookService.instance()\
+ .move_before(_get_hook(_selected_hook_item), _get_hook(prev))
+ _update_hook_buttons()
+
+
+func _on_btn_move_down_pressed() -> void:
+ var next := _selected_hook_item.get_next()
+ _selected_hook_item.move_after(next)
+ GdUnitTestSessionHookService.instance()\
+ .move_after(_get_hook(_selected_hook_item), _get_hook(next))
+ _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..cbb5b86
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd.uid
@@ -0,0 +1 @@
+uid://cmclisg3m8k2n
diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn
new file mode 100644
index 0000000..8eec309
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn
@@ -0,0 +1,148 @@
+[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")
+
+[node name="Hooks" type="ScrollContainer"]
+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="."]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="hooks_content" type="VBoxContainer" parent="HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="hooks_tree" type="Tree" parent="HBoxContainer/hooks_content"]
+unique_name_in_owner = true
+layout_direction = 2
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+hide_folding = true
+hide_root = true
+
+[node name="hook_description" type="RichTextLabel" parent="HBoxContainer/hooks_content"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 120)
+layout_mode = 2
+size_flags_vertical = 2
+bbcode_enabled = true
+text = "The test result Html reporting hook."
+scroll_active = false
+
+[node name="hook_actions" type="VBoxContainer" parent="HBoxContainer"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(80, 0)
+layout_mode = 2
+size_flags_horizontal = 0
+theme_override_constants/separation = 5
+
+[node name="btn_move_up" type="Button" parent="HBoxContainer/hook_actions"]
+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"]
+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"]
+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"]
+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_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_name_in_owner = true
+initial_position = 3
+current_screen = 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
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/logo.png
Binary files 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..0141c5e
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/logo.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bo6npdrbk02dk"
+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/TestSuiteTemplate.gd b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd
new file mode 100644
index 0000000..be5c352
--- /dev/null
+++ b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.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/TestSuiteTemplate.gd.uid b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd.uid
new file mode 100644
index 0000000..2c03cb1
--- /dev/null
+++ b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd.uid
@@ -0,0 +1 @@
+uid://dh7k6amf26gro
diff --git a/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.tscn b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.tscn
new file mode 100644
index 0000000..5ccc443
--- /dev/null
+++ b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.tscn
@@ -0,0 +1,127 @@
+[gd_scene load_steps=2 format=3 uid="uid://dte0m2endcgtu"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/templates/TestSuiteTemplate.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/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\\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("<h1>((.*?\\R?)+)<\\/h1>"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H1],
+ [regex("<h1[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h1>"), (HEADER_CENTERED_RULE + HORIZONTAL_RULE) % FONT_H1],
+ [regex("<h2>((.*?\\R?)+)<\\/h2>"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H2],
+ [regex("<h2[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h2>"), (HEADER_CENTERED_RULE + HORIZONTAL_RULE) % FONT_H1],
+ [regex("<h3>((.*?\\R?)+)<\\/h3>"), HEADER_RULE % FONT_H3],
+ [regex("<h3[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h3>"), HEADER_CENTERED_RULE % FONT_H3],
+ [regex("<h4>((.*?\\R?)+)<\\/h4>"), HEADER_RULE % FONT_H4],
+ [regex("<h4[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h4>"), HEADER_CENTERED_RULE % FONT_H4],
+ [regex("<h5>((.*?\\R?)+)<\\/h5>"), HEADER_RULE % FONT_H5],
+ [regex("<h5[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h5>"), HEADER_CENTERED_RULE % FONT_H5],
+ [regex("<h6>((.*?\\R?)+)<\\/h6>"), HEADER_RULE % FONT_H6],
+ [regex("<h6[ ]*align[ ]*=[ ]*\"center\">((.*?\\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>"), "[b]$1[/b]"],
+ [regex("[_]{2}(.*?)[_]{2}"), "[b]$1[/b]"],
+ [regex("[\\*]{2}(.*?)[\\*]{2}"), "[b]$1[/b]"],
+ # italic font
+ [regex("<i>(.*?)<\\/i>"), "[i]$1[/i]"],
+ [regex(exclude_font_size+"_(.*?)_"), "[i]$1[/i]"],
+ [regex("\\*(.*?)\\*"), "[i]$1[/i]"],
+
+ # strikethrough font
+ [regex("<s>(.*?)</s>"), "[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..6817573
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdMarkDownReader.gd.uid
@@ -0,0 +1 @@
+uid://d1dpjlnngiokr
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..ca688a3
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitPatch.gd.uid
@@ -0,0 +1 @@
+uid://b0il3gifqhk77
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..241850e
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitPatcher.gd.uid
@@ -0,0 +1 @@
+uid://b81y3i61xqoiw
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..a72d4a5
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdate.gd.uid
@@ -0,0 +1 @@
+uid://dyr407n7f3td6
diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.tscn b/addons/gdUnit4/src/update/GdUnitUpdate.tscn
new file mode 100644
index 0000000..dc0511a
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdate.tscn
@@ -0,0 +1,100 @@
+[gd_scene 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..5b6e2d4
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid
@@ -0,0 +1 @@
+uid://c0aqh8gtyaxls
diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd
new file mode 100644
index 0000000..f4ae9ac
--- /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("<h1>GdUnit Release %s</h1>" % 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:
+ ScriptEditorControls.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..fa36885
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid
@@ -0,0 +1 @@
+uid://ciyg27plnsvyr
diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn b/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn
new file mode 100644
index 0000000..7305f61
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn
@@ -0,0 +1,97 @@
+[gd_scene 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
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/border_bottom.png
Binary files 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..752bf40
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/border_bottom.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dns6mtm6xif3"
+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
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/border_top.png
Binary files 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..dae66df
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/border_top.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cvg6r1fdk1p01"
+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
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/dot1.png
Binary files 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..a7f74d7
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/dot1.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://t4fmqmj3ffcd"
+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
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/dot2.png
Binary files 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..8b5b001
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/dot2.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c2usgitbic18b"
+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
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/embedded.png
Binary files 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..a02de65
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/embedded.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bm8tdlw36fu7q"
+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
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/horizontal-line2.png
Binary files 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..3148524
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/horizontal-line2.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://w1d7ughjf1b3"
+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