diff --git a/_Core/managers/AuthManager.gd b/_Core/managers/AuthManager.gd index f218105..4234bf7 100644 --- a/_Core/managers/AuthManager.gd +++ b/_Core/managers/AuthManager.gd @@ -80,25 +80,146 @@ var current_email: String = "" # 网络请求管理 var active_request_ids: Array = [] -# 游戏 token 管理(用于 WebSocket 聊天认证) -var _game_token: String = "" +# ============ Token 管理 ============ + +# 本地存储路径常量 +const AUTH_CONFIG_PATH: String = "user://auth.cfg" + +# Token 存储(内存中,用于快速访问) +var _access_token: String = "" # JWT访问令牌(短期,用于API和WebSocket) +var _refresh_token: String = "" # JWT刷新令牌(长期,用于获取新access_token) +var _user_info: Dictionary = {} # 用户信息 +var _token_expiry: float = 0.0 # access_token过期时间(Unix时间戳) + +# 游戏 token(兼容旧代码,保留但标记为废弃) +var _game_token: String = "" # @deprecated 使用 _access_token 替代 # ============ 生命周期方法 ============ # 初始化管理器 -func _init(): +func _init() -> void: print("AuthManager 初始化完成") + _load_auth_data() # 清理资源 -func cleanup(): +func cleanup() -> void: # 取消所有活动的网络请求 for request_id in active_request_ids: NetworkManager.cancel_request(request_id) active_request_ids.clear() -# ============ 游戏 Token 管理 ============ +# ============ Token 管理 ============ -# 设置游戏 token +# 保存 Token 到内存 +# +# 参数: +# data: Dictionary - 登录响应数据 +# +# 功能: +# - 从登录响应中提取 access_token 和 refresh_token +# - 保存到内存变量中 +# - 保存用户信息 +func _save_tokens_to_memory(data: Dictionary) -> void: + if not data.has("data"): + print("⚠️ 登录响应中没有 data 字段") + return + + var token_data: Dictionary = data.data + _access_token = token_data.get("access_token", "") + _refresh_token = token_data.get("refresh_token", "") + _user_info = token_data.get("user", {}) + _token_expiry = Time.get_unix_time_from_system() + float(token_data.get("expires_in", 0)) + + # 保持兼容性:设置 _game_token + _game_token = _access_token + + print("✅ Token已保存到内存") + print(" Access Token: ", _access_token.substr(0, 20) + "...") + print(" 用户: ", _user_info.get("username", "未知")) + +# 保存 Token 到本地(ConfigFile) +# +# 参数: +# data: Dictionary - 登录响应数据 +# +# 功能: +# - 将 refresh_token 和用户信息保存到 ConfigFile +# - access_token 不保存到本地,仅保存在内存中 +func _save_tokens_to_local(data: Dictionary) -> void: + if not data.has("data"): + return + + var token_data: Dictionary = data.data + var auth_data: Dictionary = { + "refresh_token": token_data.get("refresh_token", ""), + "user_id": token_data.get("user", {}).get("id", ""), + "username": token_data.get("user", {}).get("username", ""), + "saved_at": Time.get_unix_time_from_system() + } + + var config: ConfigFile = ConfigFile.new() + config.load(AUTH_CONFIG_PATH) + config.set_value("auth", "refresh_token", auth_data["refresh_token"]) + config.set_value("auth", "user_id", auth_data["user_id"]) + config.set_value("auth", "username", auth_data["username"]) + config.set_value("auth", "saved_at", auth_data["saved_at"]) + + var error: Error = config.save(AUTH_CONFIG_PATH) + if error == OK: + print("✅ Token已保存到本地: ", AUTH_CONFIG_PATH) + else: + print("❌ 保存Token到本地失败,错误码: ", error) + +# 从本地加载 Token(游戏启动时调用) +# +# 功能: +# - 从 ConfigFile 加载 refresh_token 和用户信息 +# - access_token 需要通过 refresh_token 刷新获取 +func _load_auth_data() -> void: + if not FileAccess.file_exists(AUTH_CONFIG_PATH): + print("ℹ️ 本地不存在认证数据") + return + + var config: ConfigFile = ConfigFile.new() + var error: Error = config.load(AUTH_CONFIG_PATH) + + if error != OK: + print("❌ 加载本地认证数据失败,错误码: ", error) + return + + _refresh_token = config.get_value("auth", "refresh_token", "") + var user_id: String = config.get_value("auth", "user_id", "") + var username: String = config.get_value("auth", "username", "") + + if not _refresh_token.is_empty(): + _user_info = { + "id": user_id, + "username": username + } + print("✅ 已从本地加载认证数据") + print(" 用户: ", username) + else: + print("⚠️ 本地认证数据无效(没有 refresh_token)") + +# 清除本地认证数据(登出时调用) +# +# 功能: +# - 清除内存中的 Token +# - 删除本地 ConfigFile +func _clear_auth_data() -> void: + _access_token = "" + _refresh_token = "" + _user_info = {} + _token_expiry = 0.0 + _game_token = "" + + if FileAccess.file_exists(AUTH_CONFIG_PATH): + DirAccess.remove_absolute(AUTH_CONFIG_PATH) + print("✅ 已清除本地认证数据") + +# ============ Token 访问方法 ============ + +# 设置游戏 token(兼容旧代码,推荐使用 _save_tokens_to_memory) # # 参数: # token: String - 游戏认证 token @@ -108,18 +229,47 @@ func cleanup(): # - 从服务器响应中获取 token func set_game_token(token: String) -> void: _game_token = token + _access_token = token # 同步更新 access_token print("AuthManager: 游戏 token 已设置") # 获取游戏 token # # 返回值: -# String - 游戏 token,如果未设置则返回空字符串 +# String - access_token(如果未设置则返回空字符串) # # 使用场景: # - ChatManager 连接 WebSocket 时需要 token # - 其他需要游戏认证的场景 func get_game_token() -> String: - return _game_token + return _access_token + +# 获取 access token +# +# 返回值: +# String - JWT访问令牌 +# +# 使用场景: +# - API请求认证 +# - WebSocket聊天认证 +func get_access_token() -> String: + return _access_token + +# 获取 refresh token +# +# 返回值: +# String - JWT刷新令牌 +# +# 使用场景: +# - 刷新过期的 access token +func get_refresh_token() -> String: + return _refresh_token + +# 获取用户信息 +# +# 返回值: +# Dictionary - 用户信息字典 +func get_user_info() -> Dictionary: + return _user_info # ============ 登录相关方法 ============ @@ -449,19 +599,21 @@ func validate_verification_code(code: String) -> Dictionary: # ============ 网络响应处理 ============ # 处理登录响应 -func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary): +func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void: _reset_login_state() - + var result = ResponseHandler.handle_login_response(success, data, error_info) - + if result.should_show_toast: show_toast_message.emit(result.message, result.success) - + if result.success: - var username = "" - if data.has("data") and data.data.has("user") and data.data.user.has("username"): - username = data.data.user.username - + # 保存 Token 到内存和本地 + _save_tokens_to_memory(data) + _save_tokens_to_local(data) + + var username: String = _user_info.get("username", "") + # 延迟发送登录成功信号 await Engine.get_main_loop().create_timer(1.0).timeout login_success.emit(username) @@ -469,19 +621,21 @@ func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) login_failed.emit(result.message) # 处理验证码登录响应 -func _on_verification_login_response(success: bool, data: Dictionary, error_info: Dictionary): +func _on_verification_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void: _reset_login_state() - + var result = ResponseHandler.handle_verification_code_login_response(success, data, error_info) - + if result.should_show_toast: show_toast_message.emit(result.message, result.success) - + if result.success: - var username = "" - if data.has("data") and data.data.has("user") and data.data.user.has("username"): - username = data.data.user.username - + # 保存 Token 到内存和本地 + _save_tokens_to_memory(data) + _save_tokens_to_local(data) + + var username: String = _user_info.get("username", "") + await Engine.get_main_loop().create_timer(1.0).timeout login_success.emit(username) else: diff --git a/_Core/managers/ChatManager.gd b/_Core/managers/ChatManager.gd index 73ee935..91b8348 100644 --- a/_Core/managers/ChatManager.gd +++ b/_Core/managers/ChatManager.gd @@ -23,8 +23,6 @@ extends Node # - 所有聊天事件通过 EventSystem 广播 # ============================================================================ -class_name ChatManager - # ============================================================================ # 信号定义 (Signal Up) # ============================================================================ @@ -51,8 +49,8 @@ signal chat_error_occurred(error_code: String, message: String) # 聊天连接状态变化信号 # 参数: -# state: WebSocketManager.ConnectionState - 连接状态 -signal chat_connection_state_changed(state: WebSocketManager.ConnectionState) +# state: int - 连接状态(0=DISCONNECTED, 1=CONNECTING, 2=CONNECTED, 3=RECONNECTING, 4=ERROR) +signal chat_connection_state_changed(state: int) # 位置更新成功信号 # 参数: @@ -178,7 +176,7 @@ func get_game_token() -> String: # 连接到聊天服务器 func connect_to_chat_server() -> void: - if _websocket_manager.is_connected(): + if _websocket_manager.is_websocket_connected(): push_warning("聊天服务器已连接") return @@ -193,17 +191,17 @@ func disconnect_from_chat_server() -> void: if _is_logged_in: var logout_data := {"type": "logout"} _socket_client.emit("logout", logout_data) - _is_logged_in = False + _is_logged_in = false # 断开连接 - _websocket_manager.disconnect() + _websocket_manager.disconnect_websocket() # 检查是否已连接 # # 返回值: # bool - 是否已连接 -func is_connected() -> bool: - return _websocket_manager.is_connected() +func is_chat_connected() -> bool: + return _websocket_manager.is_websocket_connected() # ============================================================================ # 公共 API - 聊天操作 @@ -219,7 +217,7 @@ func is_connected() -> bool: # ChatManager.send_chat_message("Hello, world!", "local") func send_chat_message(content: String, scope: String = "local") -> void: # 检查连接状态 - if not _websocket_manager.is_connected(): + if not _websocket_manager.is_websocket_connected(): _handle_error("NOT_CONNECTED", "未连接到聊天服务器") return @@ -272,7 +270,7 @@ func send_chat_message(content: String, scope: String = "local") -> void: # 使用示例: # ChatManager.update_player_position(150.0, 200.0, "novice_village") func update_player_position(x: float, y: float, map_id: String) -> void: - if not _websocket_manager.is_connected(): + if not _websocket_manager.is_websocket_connected(): return var position_data := { @@ -297,10 +295,9 @@ func can_send_message() -> bool: var current_time := Time.get_unix_time_from_system() # 清理过期的时间戳 - _message_timestamps = _message_timestamps.filter( - func(timestamp: float) -> bool: - return current_time - timestamp < RATE_LIMIT_WINDOW - ) + var filter_func := func(timestamp: float) -> bool: + return current_time - timestamp < RATE_LIMIT_WINDOW + _message_timestamps = _message_timestamps.filter(filter_func) # 检查数量 return _message_timestamps.size() < RATE_LIMIT_MESSAGES @@ -458,11 +455,12 @@ func _on_socket_connected() -> void: # Socket 连接断开 func _on_socket_disconnected(_clean_close: bool) -> void: print("🔌 ChatManager: Socket 连接断开") - _is_logged_in = False + _is_logged_in = false # 连接状态变化 -func _on_connection_state_changed(state: WebSocketManager.ConnectionState) -> void: - print("📡 ChatManager: 连接状态变化 - ", WebSocketManager.ConnectionState.keys()[state]) +func _on_connection_state_changed(state: int) -> void: + var state_names := ["DISCONNECTED", "CONNECTING", "CONNECTED", "RECONNECTING", "ERROR"] + print("📡 ChatManager: 连接状态变化 - ", state_names[state]) # 发射信号 chat_connection_state_changed.emit(state) @@ -496,7 +494,7 @@ func _on_socket_event_received(event_name: String, data: Dictionary) -> void: func _handle_login_success(data: Dictionary) -> void: print("✅ ChatManager: 登录成功") - _is_logged_in = True + _is_logged_in = true _current_username = data.get("username", "") _current_map = data.get("currentMap", "") @@ -590,8 +588,8 @@ func _on_socket_error(error: String) -> void: # 发送登录消息 func _send_login_message() -> void: if _game_token.is_empty(): - push_error("无法获取游戏 token,请先调用 set_game_token() 设置 token") - _handle_error("AUTH_FAILED", "无法获取游戏 token,请先设置 token") + push_error("无法获取 access token,请确保已登录") + _handle_error("AUTH_FAILED", "无法获取 access token,请先登录") return var login_data := { @@ -600,14 +598,14 @@ func _send_login_message() -> void: } _socket_client.emit("login", login_data) - print("📤 发送登录消息") + print("📤 发送登录消息(使用 access token)") # 处理错误 func _handle_error(error_code: String, error_message: String) -> void: print("❌ ChatManager 错误: [", error_code, "] ", error_message) # 获取用户友好的错误消息 - var user_message := CHAT_ERROR_MESSAGES.get(error_code, error_message) + var user_message: String = CHAT_ERROR_MESSAGES.get(error_code, error_message) as String # 发射信号 chat_error_occurred.emit(error_code, user_message) @@ -620,7 +618,7 @@ func _handle_error(error_code: String, error_message: String) -> void: # 特殊处理认证失败 if error_code == "AUTH_FAILED" or error_code == "SESSION_EXPIRED": - _is_logged_in = False + _is_logged_in = false EventSystem.emit_event(EventNames.CHAT_LOGIN_FAILED, { "error_code": error_code }) @@ -633,11 +631,11 @@ func _record_message_timestamp() -> void: # 添加消息到当前会话历史 func _add_message_to_history(message: Dictionary) -> void: _message_history.append(message) - + # 更新最旧消息时间戳(用于历史消息加载) if _oldest_message_timestamp == 0.0 or message.timestamp < _oldest_message_timestamp: _oldest_message_timestamp = message.timestamp - + # 限制当前会话消息数量(超过后删除最旧的) if _message_history.size() > MAX_SESSION_MESSAGES: _message_history.pop_front() diff --git a/_Core/managers/WebSocketManager.gd b/_Core/managers/WebSocketManager.gd index 68974a1..a912292 100644 --- a/_Core/managers/WebSocketManager.gd +++ b/_Core/managers/WebSocketManager.gd @@ -144,8 +144,8 @@ func connect_to_game_server() -> void: _socket_client.connect_to_server(WEBSOCKET_URL) -# 断开连接 -func disconnect() -> void: +# 断开 WebSocket 连接 +func disconnect_websocket() -> void: print("=== WebSocketManager 断开连接 ===") _clean_close = true @@ -156,11 +156,11 @@ func disconnect() -> void: _socket_client.disconnect_from_server() _set_connection_state(ConnectionState.DISCONNECTED) -# 检查是否已连接 +# 检查 WebSocket 是否已连接 # # 返回值: -# bool - 是否已连接 -func is_connected() -> bool: +# bool - WebSocket 是否已连接 +func is_websocket_connected() -> bool: return _connection_state == ConnectionState.CONNECTED # 获取当前连接状态 diff --git a/_Core/systems/SocketIOClient.gd b/_Core/systems/SocketIOClient.gd index ecaa305..f71e29f 100644 --- a/_Core/systems/SocketIOClient.gd +++ b/_Core/systems/SocketIOClient.gd @@ -134,11 +134,11 @@ func disconnect_from_server() -> void: _connection_state = ConnectionState.DISCONNECTED disconnected.emit(true) -# 检查是否已连接 +# 检查 Socket 是否已连接 # # 返回值: # bool - 是否已连接 -func is_connected() -> bool: +func is_socket_connected() -> bool: return _connection_state == ConnectionState.CONNECTED # ============================================================================ @@ -155,7 +155,7 @@ func is_connected() -> bool: # socket_client.emit("login", {"type": "login", "token": "abc123"}) # socket_client.emit("chat", {"t": "chat", "content": "Hello", "scope": "local"}) func emit(event_name: String, data: Dictionary) -> void: - if not is_connected(): + if not is_socket_connected(): push_error("无法发送事件: 未连接到服务器") error_occurred.emit("未连接到服务器") return diff --git a/addons/gut/GutScene.gd b/addons/gut/GutScene.gd new file mode 100644 index 0000000..abb9689 --- /dev/null +++ b/addons/gut/GutScene.gd @@ -0,0 +1,132 @@ +extends Node2D +# ############################################################################## +# This is a wrapper around the normal and compact gui controls and serves as +# the interface between gut.gd and the gui. The GutRunner creates an instance +# of this and then this takes care of managing the different GUI controls. +# ############################################################################## +@onready var _normal_gui = $Normal +@onready var _compact_gui = $Compact + +var gut = null : + set(val): + gut = val + _set_gut(val) + + +func _ready(): + _normal_gui.switch_modes.connect(use_compact_mode.bind(true)) + _compact_gui.switch_modes.connect(use_compact_mode.bind(false)) + + _normal_gui.set_title("GUT") + _compact_gui.set_title("GUT") + + _normal_gui.align_right() + _compact_gui.to_bottom_right() + + use_compact_mode(false) + + if(get_parent() == get_tree().root): + _test_running_setup() + +func _test_running_setup(): + set_font_size(100) + _normal_gui.get_textbox().text = "hello world, how are you doing?" + +# ------------------------ +# Private +# ------------------------ +func _set_gut(val): + if(_normal_gui.get_gut() == val): + return + _normal_gui.set_gut(val) + _compact_gui.set_gut(val) + + val.start_run.connect(_on_gut_start_run) + val.end_run.connect(_on_gut_end_run) + val.start_pause_before_teardown.connect(_on_gut_pause) + val.end_pause_before_teardown.connect(_on_pause_end) + +func _set_both_titles(text): + _normal_gui.set_title(text) + _compact_gui.set_title(text) + + +# ------------------------ +# Events +# ------------------------ +func _on_gut_start_run(): + _set_both_titles('Running') + +func _on_gut_end_run(): + _set_both_titles('Finished') + +func _on_gut_pause(): + _set_both_titles('-- Paused --') + +func _on_pause_end(): + _set_both_titles('Running') + + +# ------------------------ +# Public +# ------------------------ +func get_textbox(): + return _normal_gui.get_textbox() + + +func set_font_size(new_size): + var rtl = _normal_gui.get_textbox() + + rtl.set('theme_override_font_sizes/bold_italics_font_size', new_size) + rtl.set('theme_override_font_sizes/bold_font_size', new_size) + rtl.set('theme_override_font_sizes/italics_font_size', new_size) + rtl.set('theme_override_font_sizes/normal_font_size', new_size) + + +func set_font(font_name): + _set_all_fonts_in_rtl(_normal_gui.get_textbox(), font_name) + + +func _set_font(rtl, font_name, custom_name): + if(font_name == null): + rtl.remove_theme_font_override(custom_name) + else: + var font_path = 'res://addons/gut/fonts/' + font_name + '.ttf' + if(FileAccess.file_exists(font_path)): + var dyn_font = FontFile.new() + dyn_font.load_dynamic_font('res://addons/gut/fonts/' + font_name + '.ttf') + rtl.add_theme_font_override(custom_name, dyn_font) + + +func _set_all_fonts_in_rtl(rtl, base_name): + if(base_name == 'Default'): + _set_font(rtl, null, 'normal_font') + _set_font(rtl, null, 'bold_font') + _set_font(rtl, null, 'italics_font') + _set_font(rtl, null, 'bold_italics_font') + else: + _set_font(rtl, base_name + '-Regular', 'normal_font') + _set_font(rtl, base_name + '-Bold', 'bold_font') + _set_font(rtl, base_name + '-Italic', 'italics_font') + _set_font(rtl, base_name + '-BoldItalic', 'bold_italics_font') + + +func set_default_font_color(color): + _normal_gui.get_textbox().set('custom_colors/default_color', color) + + +func set_background_color(color): + _normal_gui.set_bg_color(color) + + +func use_compact_mode(should=true): + _compact_gui.visible = should + _normal_gui.visible = !should + + +func set_opacity(val): + _normal_gui.modulate.a = val + _compact_gui.modulate.a = val + +func set_title(text): + _set_both_titles(text) diff --git a/addons/gut/GutScene.gd.uid b/addons/gut/GutScene.gd.uid new file mode 100644 index 0000000..46e710f --- /dev/null +++ b/addons/gut/GutScene.gd.uid @@ -0,0 +1 @@ +uid://bw7tukh738kw1 diff --git a/addons/gut/GutScene.tscn b/addons/gut/GutScene.tscn new file mode 100644 index 0000000..82638cc --- /dev/null +++ b/addons/gut/GutScene.tscn @@ -0,0 +1,16 @@ +[gd_scene load_steps=4 format=3 uid="uid://m28heqtswbuq"] + +[ext_resource type="Script" uid="uid://bw7tukh738kw1" path="res://addons/gut/GutScene.gd" id="1_b4m8y"] +[ext_resource type="PackedScene" uid="uid://duxblir3vu8x7" path="res://addons/gut/gui/NormalGui.tscn" id="2_j6ywb"] +[ext_resource type="PackedScene" uid="uid://cnqqdfsn80ise" path="res://addons/gut/gui/MinGui.tscn" id="3_3glw1"] + +[node name="GutScene" type="Node2D"] +script = ExtResource("1_b4m8y") + +[node name="Normal" parent="." instance=ExtResource("2_j6ywb")] + +[node name="Compact" parent="." instance=ExtResource("3_3glw1")] +offset_left = 5.0 +offset_top = 273.0 +offset_right = 265.0 +offset_bottom = 403.0 diff --git a/addons/gut/LICENSE.md b/addons/gut/LICENSE.md new file mode 100644 index 0000000..a38ac23 --- /dev/null +++ b/addons/gut/LICENSE.md @@ -0,0 +1,22 @@ +The MIT License (MIT) +===================== + +Copyright (c) 2018 Tom "Butch" Wesley + +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/gut/UserFileViewer.gd b/addons/gut/UserFileViewer.gd new file mode 100644 index 0000000..73b6a00 --- /dev/null +++ b/addons/gut/UserFileViewer.gd @@ -0,0 +1,52 @@ +extends Window + +@onready var rtl = $TextDisplay/RichTextLabel + +func _get_file_as_text(path): + var to_return = null + var f = FileAccess.open(path, FileAccess.READ) + if(f != null): + to_return = f.get_as_text() + else: + to_return = str('ERROR: Could not open file. Error code ', FileAccess.get_open_error()) + return to_return + +func _ready(): + rtl.clear() + +func _on_OpenFile_pressed(): + $FileDialog.popup_centered() + +func _on_FileDialog_file_selected(path): + show_file(path) + +func _on_Close_pressed(): + self.hide() + +func show_file(path): + var text = _get_file_as_text(path) + if(text == ''): + text = '' + rtl.set_text(text) + self.window_title = path + +func show_open(): + self.popup_centered() + $FileDialog.popup_centered() + +func get_rich_text_label(): + return $TextDisplay/RichTextLabel + +func _on_Home_pressed(): + rtl.scroll_to_line(0) + +func _on_End_pressed(): + rtl.scroll_to_line(rtl.get_line_count() -1) + +func _on_Copy_pressed(): + return + # OS.clipboard = rtl.text + +func _on_file_dialog_visibility_changed(): + if rtl.text.length() == 0 and not $FileDialog.visible: + self.hide() diff --git a/addons/gut/UserFileViewer.gd.uid b/addons/gut/UserFileViewer.gd.uid new file mode 100644 index 0000000..50a409a --- /dev/null +++ b/addons/gut/UserFileViewer.gd.uid @@ -0,0 +1 @@ +uid://x51wilphva3d diff --git a/addons/gut/UserFileViewer.tscn b/addons/gut/UserFileViewer.tscn new file mode 100644 index 0000000..15e2e86 --- /dev/null +++ b/addons/gut/UserFileViewer.tscn @@ -0,0 +1,92 @@ +[gd_scene load_steps=2 format=3 uid="uid://bsm7wtt1gie4v"] + +[ext_resource type="Script" uid="uid://x51wilphva3d" path="res://addons/gut/UserFileViewer.gd" id="1"] + +[node name="UserFileViewer" type="Window"] +exclusive = true +script = ExtResource("1") + +[node name="FileDialog" type="FileDialog" parent="."] +access = 1 +show_hidden_files = true +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="TextDisplay" type="ColorRect" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 8.0 +offset_right = -10.0 +offset_bottom = -65.0 +color = Color(0.2, 0.188235, 0.188235, 1) + +[node name="RichTextLabel" type="RichTextLabel" parent="TextDisplay"] +anchor_right = 1.0 +anchor_bottom = 1.0 +focus_mode = 2 +text = "In publishing and graphic design, Lorem ipsum is a placeholder text commonly used to demonstrate the visual form of a document or a typeface without relying on meaningful content. Lorem ipsum may be used before final copy is available, but it may also be used to temporarily replace copy in a process called greeking, which allows designers to consider form without the meaning of the text influencing the design. + +Lorem ipsum is typically a corrupted version of De finibus bonorum et malorum, a first-century BCE text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical, improper Latin. + +Versions of the Lorem ipsum text have been used in typesetting at least since the 1960s, when it was popularized by advertisements for Letraset transfer sheets. Lorem ipsum was introduced to the digital world in the mid-1980s when Aldus employed it in graphic and word-processing templates for its desktop publishing program PageMaker. Other popular word processors including Pages and Microsoft Word have since adopted Lorem ipsum as well." +selection_enabled = true + +[node name="OpenFile" type="Button" parent="."] +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -158.0 +offset_top = -50.0 +offset_right = -84.0 +offset_bottom = -30.0 +text = "Open File" + +[node name="Home" type="Button" parent="."] +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -478.0 +offset_top = -50.0 +offset_right = -404.0 +offset_bottom = -30.0 +text = "Home" + +[node name="Copy" type="Button" parent="."] +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 160.0 +offset_top = -50.0 +offset_right = 234.0 +offset_bottom = -30.0 +text = "Copy" + +[node name="End" type="Button" parent="."] +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -318.0 +offset_top = -50.0 +offset_right = -244.0 +offset_bottom = -30.0 +text = "End" + +[node name="Close" type="Button" parent="."] +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 10.0 +offset_top = -50.0 +offset_right = 80.0 +offset_bottom = -30.0 +text = "Close" + +[connection signal="file_selected" from="FileDialog" to="." method="_on_FileDialog_file_selected"] +[connection signal="visibility_changed" from="FileDialog" to="." method="_on_file_dialog_visibility_changed"] +[connection signal="pressed" from="OpenFile" to="." method="_on_OpenFile_pressed"] +[connection signal="pressed" from="Home" to="." method="_on_Home_pressed"] +[connection signal="pressed" from="Copy" to="." method="_on_Copy_pressed"] +[connection signal="pressed" from="End" to="." method="_on_End_pressed"] +[connection signal="pressed" from="Close" to="." method="_on_Close_pressed"] diff --git a/addons/gut/autofree.gd b/addons/gut/autofree.gd new file mode 100644 index 0000000..240cd93 --- /dev/null +++ b/addons/gut/autofree.gd @@ -0,0 +1,86 @@ +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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. +# +# ############################################################################## +# Class used to keep track of objects to be freed and utilities to free them. +# ############################################################################## +var _to_free = [] +var _to_queue_free = [] +var _ref_counted_doubles = [] +var _all_instance_ids = [] + + +func _add_instance_id(thing): + if(thing.has_method("get_instance_id")): + _all_instance_ids.append(thing.get_instance_id()) + + +func add_free(thing): + if(typeof(thing) == TYPE_OBJECT): + _add_instance_id(thing) + if(!thing is RefCounted): + _to_free.append(thing) + elif(GutUtils.is_double(thing)): + _ref_counted_doubles.append(thing) + + +func add_queue_free(thing): + if(typeof(thing) == TYPE_OBJECT): + _add_instance_id(thing) + _to_queue_free.append(thing) + + +func get_queue_free_count(): + return _to_queue_free.size() + + +func get_free_count(): + return _to_free.size() + + +func free_all(): + for node in _to_free: + if(is_instance_valid(node)): + if(GutUtils.is_double(node)): + node.__gutdbl_done() + node.free() + _to_free.clear() + + for i in range(_to_queue_free.size()): + if(is_instance_valid(_to_queue_free[i])): + _to_queue_free[i].queue_free() + _to_queue_free.clear() + + for ref_dbl in _ref_counted_doubles: + ref_dbl.__gutdbl_done() + _ref_counted_doubles.clear() + + _all_instance_ids.clear() + + +func has_instance_id(id): + return _all_instance_ids.has(id) \ No newline at end of file diff --git a/addons/gut/autofree.gd.uid b/addons/gut/autofree.gd.uid new file mode 100644 index 0000000..13162c8 --- /dev/null +++ b/addons/gut/autofree.gd.uid @@ -0,0 +1 @@ +uid://bxjfriqxgwe0r diff --git a/addons/gut/awaiter.gd b/addons/gut/awaiter.gd new file mode 100644 index 0000000..77e54a5 --- /dev/null +++ b/addons/gut/awaiter.gd @@ -0,0 +1,201 @@ +extends Node + +class AwaitLogger: + var _time_waited = 0.0 + var logger = GutUtils.get_logger() + var waiting_on = "nothing" + var logged_initial_message = false + var wait_log_delay := 1.0 + var disabled = false + + func waited(x): + _time_waited += x + if(!logged_initial_message and _time_waited >= wait_log_delay): + log_it() + logged_initial_message = true + + + func reset(): + _time_waited = 0.0 + logged_initial_message = false + + + func log_it(): + if(!disabled): + var msg = str("--- Awaiting ", waiting_on, " ---") + logger.wait_msg(msg) + + + + +signal timeout +signal wait_started + +var await_logger = AwaitLogger.new() +var _wait_time := 0.0 +var _wait_process_frames := 0 +var _wait_physics_frames := 0 +var _signal_to_wait_on = null + +var _predicate_method = null +var _waiting_for_predicate_to_be = null + +var _predicate_time_between := 0.0 +var _predicate_time_between_elpased := 0.0 + +var _elapsed_time := 0.0 +var _elapsed_frames := 0 + +var _did_last_wait_timeout = false +var did_last_wait_timeout = false : + get: return _did_last_wait_timeout + set(val): push_error("Cannot set did_last_wait_timeout") + + + +func _ready() -> void: + get_tree().process_frame.connect(_on_tree_process_frame) + get_tree().physics_frame.connect(_on_tree_physics_frame) + + +func _on_tree_process_frame(): + # Count frames here instead of in _process so that tree order never + # makes a difference and the count/signaling happens outside of + # _process being called. + if(_wait_process_frames > 0): + _elapsed_frames += 1 + if(_elapsed_frames > _wait_process_frames): + _end_wait() + + +func _on_tree_physics_frame(): + # Count frames here instead of in _physics_process so that tree order never + # makes a difference and the count/signaling happens outside of + # _physics_process being called. + if(_wait_physics_frames != 0): + _elapsed_frames += 1 + if(_elapsed_frames > _wait_physics_frames): + _end_wait() + + +func _physics_process(delta): + if(is_waiting()): + await_logger.waited(delta) + + if(_wait_time != 0.0): + _elapsed_time += delta + if(_elapsed_time >= _wait_time): + _end_wait() + + if(_predicate_method != null): + _predicate_time_between_elpased += delta + if(_predicate_time_between_elpased >= _predicate_time_between): + _predicate_time_between_elpased = 0.0 + var result = _predicate_method.call() + if(_waiting_for_predicate_to_be == false): + if(typeof(result) != TYPE_BOOL or result != true): + _end_wait() + else: + if(typeof(result) == TYPE_BOOL and result == _waiting_for_predicate_to_be): + _end_wait() + + +func _end_wait(): + await_logger.reset() + # Check for time before checking for frames so that the extra frames added + # when waiting on a signal do not cause a false negative for timing out. + if(_wait_time > 0): + _did_last_wait_timeout = _elapsed_time >= _wait_time + elif(_wait_physics_frames > 0): + _did_last_wait_timeout = _elapsed_frames >= _wait_physics_frames + elif(_wait_process_frames > 0): + _did_last_wait_timeout = _elapsed_frames >= _wait_process_frames + + if(_signal_to_wait_on != null and \ + is_instance_valid(_signal_to_wait_on.get_object()) and \ + _signal_to_wait_on.is_connected(_signal_callback)): + _signal_to_wait_on.disconnect(_signal_callback) + + _wait_process_frames = 0 + _wait_time = 0.0 + _wait_physics_frames = 0 + _signal_to_wait_on = null + _predicate_method = null + _elapsed_time = 0.0 + _elapsed_frames = 0 + timeout.emit() + + +const ARG_NOT_SET = '_*_argument_*_is_*_not_set_*_' +func _signal_callback( + _arg1=ARG_NOT_SET, _arg2=ARG_NOT_SET, _arg3=ARG_NOT_SET, + _arg4=ARG_NOT_SET, _arg5=ARG_NOT_SET, _arg6=ARG_NOT_SET, + _arg7=ARG_NOT_SET, _arg8=ARG_NOT_SET, _arg9=ARG_NOT_SET): + + _signal_to_wait_on.disconnect(_signal_callback) + # DO NOT _end_wait here. For other parts of the test to get the signal that + # was waited on, we have to wait for another frames. For example, the + # signal_watcher doesn't get the signal in time if we don't do this. + _wait_process_frames = 1 + + +func wait_seconds(x, msg=''): + await_logger.waiting_on = str(x, " seconds ", msg) + _did_last_wait_timeout = false + _wait_time = x + wait_started.emit() + + +func wait_process_frames(x, msg=''): + await_logger.waiting_on = str(x, " idle frames ", msg) + _did_last_wait_timeout = false + _wait_process_frames = x + wait_started.emit() + + +func wait_physics_frames(x, msg=''): + await_logger.waiting_on = str(x, " physics frames ", msg) + _did_last_wait_timeout = false + _wait_physics_frames = x + wait_started.emit() + + +func wait_for_signal(the_signal : Signal, max_time, msg=''): + await_logger.waiting_on = str("signal ", the_signal.get_name(), " or ", max_time, "s ", msg) + _did_last_wait_timeout = false + the_signal.connect(_signal_callback) + _signal_to_wait_on = the_signal + _wait_time = max_time + wait_started.emit() + + +func wait_until(predicate_function: Callable, max_time, time_between_calls:=0.0, msg=''): + await_logger.waiting_on = str("callable to return TRUE or ", max_time, "s. ", msg) + _predicate_time_between = time_between_calls + _predicate_method = predicate_function + _wait_time = max_time + + _waiting_for_predicate_to_be = true + _predicate_time_between_elpased = 0.0 + _did_last_wait_timeout = false + + wait_started.emit() + + +func wait_while(predicate_function: Callable, max_time, time_between_calls:=0.0, msg=''): + await_logger.waiting_on = str("callable to return FALSE or ", max_time, "s. ", msg) + _predicate_time_between = time_between_calls + _predicate_method = predicate_function + _wait_time = max_time + + _waiting_for_predicate_to_be = false + _predicate_time_between_elpased = 0.0 + _did_last_wait_timeout = false + + wait_started.emit() + + +func is_waiting(): + return _wait_time != 0.0 || \ + _wait_physics_frames != 0 || \ + _wait_process_frames != 0 diff --git a/addons/gut/awaiter.gd.uid b/addons/gut/awaiter.gd.uid new file mode 100644 index 0000000..d8cae05 --- /dev/null +++ b/addons/gut/awaiter.gd.uid @@ -0,0 +1 @@ +uid://ccu4ww35edtdi diff --git a/addons/gut/cli/change_project_warnings.gd b/addons/gut/cli/change_project_warnings.gd new file mode 100644 index 0000000..9dcf7d2 --- /dev/null +++ b/addons/gut/cli/change_project_warnings.gd @@ -0,0 +1,239 @@ +extends SceneTree + +var Optparse = load('res://addons/gut/cli/optparse.gd') +var WarningsManager = load("res://addons/gut/warnings_manager.gd") +const WARN_VALUE_PRINT_POSITION = 36 + +var godot_default_warnings = { + "assert_always_false": 1, "assert_always_true": 1, "confusable_identifier": 1, + "confusable_local_declaration": 1, "confusable_local_usage": 1, "constant_used_as_function": 1, + "deprecated_keyword": 1, "empty_file": 1, "enable": true, + "exclude_addons": true, "function_used_as_property": 1, "get_node_default_without_onready": 2, + "incompatible_ternary": 1, "inference_on_variant": 2, "inferred_declaration": 0, + "int_as_enum_without_cast": 1, "int_as_enum_without_match": 1, "integer_division": 1, + "narrowing_conversion": 1, "native_method_override": 2, "onready_with_export": 2, + "property_used_as_function": 1, "redundant_await": 1, "redundant_static_unload": 1, + "renamed_in_godot_4_hint": 1, "return_value_discarded": 0, "shadowed_global_identifier": 1, + "shadowed_variable": 1, "shadowed_variable_base_class": 1, "standalone_expression": 1, + "standalone_ternary": 1, "static_called_on_instance": 1, "unassigned_variable": 1, + "unassigned_variable_op_assign": 1, "unreachable_code": 1, "unreachable_pattern": 1, + "unsafe_call_argument": 0, "unsafe_cast": 0, "unsafe_method_access": 0, + "unsafe_property_access": 0, "unsafe_void_return": 1, "untyped_declaration": 0, + "unused_local_constant": 1, "unused_parameter": 1, "unused_private_class_variable": 1, + "unused_signal": 1, "unused_variable": 1 +} + +var gut_default_changes = { + "exclude_addons": false, "redundant_await": 0, +} + +var warning_settings = {} + +func _setup_warning_settings(): + warning_settings["godot_default"] = godot_default_warnings + warning_settings["current"] = WarningsManager.create_warnings_dictionary_from_project_settings() + warning_settings["all_warn"] = WarningsManager.create_warn_all_warnings_dictionary() + + var gut_default = godot_default_warnings.duplicate() + gut_default.merge(gut_default_changes, true) + warning_settings["gut_default"] = gut_default + + +func _warn_value_to_s(value): + var readable = str(value).capitalize() + if(typeof(value) == TYPE_INT): + readable = WarningsManager.WARNING_LOOKUP.get(value, str(readable, ' ???')) + readable = readable.capitalize() + return readable + + +func _human_readable(warnings): + var to_return = "" + for key in warnings: + var readable = _warn_value_to_s(warnings[key]) + to_return += str(key.capitalize().rpad(35, ' '), readable, "\n") + return to_return + + +func _dump_settings(which): + if(warning_settings.has(which)): + GutUtils.pretty_print(warning_settings[which]) + else: + print("UNKNOWN print option ", which) + + +func _print_settings(which): + if(warning_settings.has(which)): + print(_human_readable(warning_settings[which])) + else: + print("UNKNOWN print option ", which) + + +func _apply_settings(which): + if(!warning_settings.has(which)): + print("UNKNOWN set option ", which) + return + + var pre_settings = warning_settings["current"] + var new_settings = warning_settings[which] + + if(new_settings == pre_settings): + print("-- Settings are the same, no changes were made --") + return + + WarningsManager.apply_warnings_dictionary(new_settings) + ProjectSettings.save() + print("-- Project Warning Settings have been updated --") + print(_diff_changes_text(pre_settings)) + + +func _diff_text(w1, w2, diff_col_pad=10): + var to_return = "" + for key in w1: + var v1_text = _warn_value_to_s(w1[key]) + var v2_text = _warn_value_to_s(w2[key]) + var diff_text = v1_text + var prefix = " " + + if(v1_text != v2_text): + var diff_prefix = " " + if(w1[key] > w2[key]): + diff_prefix = "-" + else: + diff_prefix = "+" + prefix = "* " + diff_text = str(v1_text.rpad(diff_col_pad, ' '), diff_prefix, v2_text) + + to_return += str(str(prefix, key.capitalize()).rpad(WARN_VALUE_PRINT_POSITION, ' '), diff_text, "\n") + + return to_return.rstrip("\n") + + +func _diff_changes_text(pre_settings): + var orig_diff_text = _diff_text( + pre_settings, + WarningsManager.create_warnings_dictionary_from_project_settings(), + 0) + # these next two lines are fragile and brute force...enjoy + var diff_text = orig_diff_text.replace("-", " -> ") + diff_text = diff_text.replace("+", " -> ") + + if(orig_diff_text == diff_text): + diff_text += "\n-- No changes were made --" + else: + diff_text += "\nChanges will not be visible in Godot until it is restarted.\n" + diff_text += "Even if it asks you to reload...Maybe. Probably." + + return diff_text + + + +func _diff(name_1, name_2): + if(warning_settings.has(name_1) and warning_settings.has(name_2)): + var c2_pad = name_1.length() + 2 + var heading = str(" ".repeat(WARN_VALUE_PRINT_POSITION), name_1.rpad(c2_pad, ' '), name_2, "\n") + heading += str( + " ".repeat(WARN_VALUE_PRINT_POSITION), + "-".repeat(name_1.length()).rpad(c2_pad, " "), + "-".repeat(name_2.length()), + "\n") + + var text = _diff_text(warning_settings[name_1], warning_settings[name_2], c2_pad) + + print(heading) + print(text) + + var diff_count = 0 + for line in text.split("\n"): + if(!line.begins_with(" ")): + diff_count += 1 + + if(diff_count == 0): + print('-- [', name_1, "] and [", name_2, "] are the same --") + else: + print('-- There are ', diff_count, ' differences between [', name_1, "] and [", name_2, "] --") + else: + print("One or more unknown Warning Level Names:, [", name_1, "] [", name_2, "]") + + +func _set_settings(nvps): + var pre_settings = warning_settings["current"] + for i in range(nvps.size()/2): + var s_name = nvps[i * 2] + var s_value = nvps[i * 2 + 1] + if(godot_default_warnings.has(s_name)): + var t = typeof(godot_default_warnings[s_name]) + if(t == TYPE_INT): + s_value = s_value.to_int() + elif(t == TYPE_BOOL): + s_value = s_value.to_lower() == 'true' + + WarningsManager.set_project_setting_warning(s_name, s_value) + ProjectSettings.save() + print(_diff_changes_text(pre_settings)) + + + +func _setup_options(): + var opts = Optparse.new() + opts.banner = """ + This script prints info about or sets the warning settings for the project. + Each action requires one or more Warning Level Names. + + Warning Level Names: + * current The current settings for the project. + * godot_default The default settings for Godot. + * gut_default The warning settings that is used when developing GUT. + * all_warn Everything set to warn. + """.dedent() + + opts.add('-h', false, 'Print this help') + opts.add('-set', [], "Sets a single setting in the project settings and saves.\n" + + "Use -dump to see a list of setting names and values.\n" + + "Example: -set enabled,true -set unsafe_cast,2 -set unreachable_code,0") + opts.add_heading(" Actions (require Warning Level Name)") + opts.add('-diff', [], "Shows the difference between two Warning Level Names.\n" + + "Example: -diff current,all_warn") + opts.add('-dump', 'none', "Prints a dictionary of the warning values.") + opts.add('-print', 'none', "Print human readable warning values.") + opts.add('-apply', 'none', "Applys one of the Warning Level Names to the project settings. You should restart after using this") + + return opts + +func _print_help(opts): + opts.print_help() + + + +func _init(): + # Testing might set this flag but it should never be disabled for this tool + # or it cannot save project settings, but says it did. Sneakily use the + # private property to get around this property being read-only. Don't + # try this at home. + WarningsManager._disabled = false + + _setup_warning_settings() + + var opts = _setup_options() + opts.parse() + + if(opts.unused.size() != 0): + opts.print_help() + print("Unknown arguments ", opts.unused) + if(opts.values.h): + opts.print_help() + elif(opts.values.print != 'none'): + _print_settings(opts.values.print) + elif(opts.values.dump != 'none'): + _dump_settings(opts.values.dump) + elif(opts.values.apply != 'none'): + _apply_settings(opts.values.apply ) + elif(opts.values.diff.size() == 2): + _diff(opts.values.diff[0], opts.values.diff[1]) + elif(opts.values.set.size() % 2 == 0): + _set_settings(opts.values.set) + else: + opts.print_help() + print("You didn't specify any options or too many or not the right size or something invalid. I don't know what you want to do.") + + quit() \ No newline at end of file diff --git a/addons/gut/cli/change_project_warnings.gd.uid b/addons/gut/cli/change_project_warnings.gd.uid new file mode 100644 index 0000000..c8c765d --- /dev/null +++ b/addons/gut/cli/change_project_warnings.gd.uid @@ -0,0 +1 @@ +uid://1pauyfnd1cre diff --git a/addons/gut/cli/gut_cli.gd b/addons/gut/cli/gut_cli.gd new file mode 100644 index 0000000..66ec02d --- /dev/null +++ b/addons/gut/cli/gut_cli.gd @@ -0,0 +1,315 @@ +extends Node + +var Optparse = load('res://addons/gut/cli/optparse.gd') +var Gut = load('res://addons/gut/gut.gd') +var GutRunner = load('res://addons/gut/gui/GutRunner.tscn') + +# ------------------------------------------------------------------------------ +# Helper class to resolve the various different places where an option can +# be set. Using the get_value method will enforce the order of precedence of: +# 1. command line value +# 2. config file value +# 3. default value +# +# The idea is that you set the base_opts. That will get you a copies of the +# hash with null values for the other types of values. Lower precedented hashes +# will punch through null values of higher precedented hashes. +# ------------------------------------------------------------------------------ +class OptionResolver: + var base_opts = {} + var cmd_opts = {} + var config_opts = {} + + + func get_value(key): + return _nvl(cmd_opts[key], _nvl(config_opts[key], base_opts[key])) + + func set_base_opts(opts): + base_opts = opts + cmd_opts = _null_copy(opts) + config_opts = _null_copy(opts) + + # creates a copy of a hash with all values null. + func _null_copy(h): + var new_hash = {} + for key in h: + new_hash[key] = null + return new_hash + + func _nvl(a, b): + if(a == null): + return b + else: + return a + + func _string_it(h): + var to_return = '' + for key in h: + to_return += str('(',key, ':', _nvl(h[key], 'NULL'), ')') + return to_return + + func to_s(): + return str("base:\n", _string_it(base_opts), "\n", \ + "config:\n", _string_it(config_opts), "\n", \ + "cmd:\n", _string_it(cmd_opts), "\n", \ + "resolved:\n", _string_it(get_resolved_values())) + + func get_resolved_values(): + var to_return = {} + for key in base_opts: + to_return[key] = get_value(key) + return to_return + + func to_s_verbose(): + var to_return = '' + var resolved = get_resolved_values() + for key in base_opts: + to_return += str(key, "\n") + to_return += str(' default: ', _nvl(base_opts[key], 'NULL'), "\n") + to_return += str(' config: ', _nvl(config_opts[key], ' --'), "\n") + to_return += str(' cmd: ', _nvl(cmd_opts[key], ' --'), "\n") + to_return += str(' final: ', _nvl(resolved[key], 'NULL'), "\n") + + return to_return + +# ------------------------------------------------------------------------------ +# Here starts the actual script that uses the Options class to kick off Gut +# and run your tests. +# ------------------------------------------------------------------------------ +var _gut_config = load('res://addons/gut/gut_config.gd').new() + +# array of command line options specified +var _final_opts = [] + + +func setup_options(options, font_names): + var opts = Optparse.new() + opts.banner =\ +""" +The GUT CLI +----------- +The default behavior for GUT is to load options from a res://.gutconfig.json if +it exists. Any options specified on the command line will take precedence over +options specified in the gutconfig file. You can specify a different gutconfig +file with the -gconfig option. + +To generate a .gutconfig.json file you can use -gprint_gutconfig_sample +To see the effective values of a CLI command and a gutconfig use -gpo + +Values for options can be supplied using: + option=value # no space around "=" + option value # a space between option and value w/o = + +Options whose values are lists/arrays can be specified multiple times: + -gdir=a,b + -gdir c,d + -gdir e + # results in -gdir equaling [a, b, c, d, e] + +To not use an empty value instead of a default value, specifiy the option with +an immediate "=": + -gconfig= +""" + opts.add_heading("Test Config:") + opts.add('-gdir', options.dirs, 'List of directories to search for test scripts in.') + opts.add('-ginclude_subdirs', false, 'Flag to include all subdirectories specified with -gdir.') + opts.add('-gtest', [], 'List of full paths to test scripts to run.') + opts.add('-gprefix', options.prefix, 'Prefix used to find tests when specifying -gdir. Default "[default]".') + opts.add('-gsuffix', options.suffix, 'Test script suffix, including .gd extension. Default "[default]".') + opts.add('-gconfig', 'res://.gutconfig.json', 'The config file to load options from. The default is [default]. Use "-gconfig=" to not use a config file.') + opts.add('-gpre_run_script', '', 'pre-run hook script path') + opts.add('-gpost_run_script', '', 'post-run hook script path') + opts.add('-gerrors_do_not_cause_failure', false, 'When an internal GUT error occurs tests will fail. With this option set, that does not happen.') + opts.add('-gdouble_strategy', 'SCRIPT_ONLY', 'Default strategy to use when doubling. Valid values are [INCLUDE_NATIVE, SCRIPT_ONLY]. Default "[default]"') + + opts.add_heading("Run Options:") + opts.add('-gselect', '', 'All scripts that contain the specified string in their filename will be ran') + opts.add('-ginner_class', '', 'Only run inner classes that contain the specified string in their name.') + opts.add('-gunit_test_name', '', 'Any test that contains the specified text will be run, all others will be skipped.') + opts.add('-gexit', false, 'Exit after running tests. If not specified you have to manually close the window.') + opts.add('-gexit_on_success', false, 'Only exit if zero tests fail.') + opts.add('-gignore_pause', false, 'Ignores any calls to pause_before_teardown.') + opts.add('-gno_error_tracking', false, 'Disable error tracking.') + opts.add('-gfailure_error_types', options.failure_error_types, 'Error types that will cause tests to fail if the are encountered during the execution of a test. Default "[default]"') + + opts.add_heading("Display Settings:") + opts.add('-glog', options.log_level, 'Log level [0-3]. Default [default]') + opts.add('-ghide_orphans', false, 'Display orphan counts for tests and scripts. Default [default].') + opts.add('-gmaximize', false, 'Maximizes test runner window to fit the viewport.') + opts.add('-gcompact_mode', false, 'The runner will be in compact mode. This overrides -gmaximize.') + opts.add('-gopacity', options.opacity, 'Set opacity of test runner window. Use range 0 - 100. 0 = transparent, 100 = opaque.') + opts.add('-gdisable_colors', false, 'Disable command line colors.') + opts.add('-gfont_name', options.font_name, str('Valid values are: ', font_names, '. Default "[default]"')) + opts.add('-gfont_size', options.font_size, 'Font size, default "[default]"') + opts.add('-gbackground_color', options.background_color, 'Background color as an html color, default "[default]"') + opts.add('-gfont_color',options.font_color, 'Font color as an html color, default "[default]"') + opts.add('-gpaint_after', options.paint_after, 'Delay before GUT will add a 1 frame pause to paint the screen/GUI. default [default]') + opts.add('-gwait_log_delay', options.wait_log_delay, 'Delay before GUT will print a message to indicate a test is awaiting one of the wait_* methods. Default [default]') + + opts.add_heading("Result Export:") + opts.add('-gjunit_xml_file', options.junit_xml_file, 'Export results of run to this file in the Junit XML format.') + opts.add('-gjunit_xml_timestamp', options.junit_xml_timestamp, 'Include a timestamp in the -gjunit_xml_file, default [default]') + + opts.add_heading("Help:") + opts.add('-gh', false, 'Print this help. You did this to see this, so you probably understand.') + opts.add('-gpo', false, 'Print option values from all sources and the value used.') + opts.add('-gprint_gutconfig_sample', false, 'Print out json that can be used to make a gutconfig file.') + + # run as in editor, for shelling out purposes through Editor. + var o = opts.add('-graie', false, 'do not use') + o.show_in_help = false + return opts + + +# Parses options, applying them to the _tester or setting values +# in the options struct. +func extract_command_line_options(from, to): + to.compact_mode = from.get_value_or_null('-gcompact_mode') + to.config_file = from.get_value_or_null('-gconfig') + to.dirs = from.get_value_or_null('-gdir') + to.disable_colors = from.get_value_or_null('-gdisable_colors') + to.double_strategy = from.get_value_or_null('-gdouble_strategy') + to.errors_do_not_cause_failure = from.get_value_or_null('-gerrors_do_not_cause_failure') + to.hide_orphans = from.get_value_or_null('-ghide_orphans') + to.ignore_pause = from.get_value_or_null('-gignore_pause') + to.include_subdirs = from.get_value_or_null('-ginclude_subdirs') + to.inner_class = from.get_value_or_null('-ginner_class') + to.log_level = from.get_value_or_null('-glog') + to.opacity = from.get_value_or_null('-gopacity') + to.post_run_script = from.get_value_or_null('-gpost_run_script') + to.pre_run_script = from.get_value_or_null('-gpre_run_script') + to.prefix = from.get_value_or_null('-gprefix') + to.selected = from.get_value_or_null('-gselect') + to.should_exit = from.get_value_or_null('-gexit') + to.should_exit_on_success = from.get_value_or_null('-gexit_on_success') + to.should_maximize = from.get_value_or_null('-gmaximize') + to.suffix = from.get_value_or_null('-gsuffix') + to.tests = from.get_value_or_null('-gtest') + to.unit_test_name = from.get_value_or_null('-gunit_test_name') + to.wait_log_delay = from.get_value_or_null('-gwait_log_delay') + + to.background_color = from.get_value_or_null('-gbackground_color') + to.font_color = from.get_value_or_null('-gfont_color') + to.font_name = from.get_value_or_null('-gfont_name') + to.font_size = from.get_value_or_null('-gfont_size') + to.paint_after = from.get_value_or_null('-gpaint_after') + + to.junit_xml_file = from.get_value_or_null('-gjunit_xml_file') + to.junit_xml_timestamp = from.get_value_or_null('-gjunit_xml_timestamp') + + to.failure_error_types = from.get_value_or_null('-gfailure_error_types') + to.no_error_tracking = from.get_value_or_null('-gno_error_tracking') + to.raie = from.get_value_or_null('-graie') + + + +func _print_gutconfigs(values): + var header = """Here is a sample of a full .gutconfig.json file. +You do not need to specify all values in your own file. The values supplied in +this sample are what would be used if you ran gut w/o the -gprint_gutconfig_sample +option. Option priority is: command-line, .gutconfig, default).""" + print("\n", header.replace("\n", ' '), "\n") + var resolved = values + + # remove_at some options that don't make sense to be in config + resolved.erase("config_file") + resolved.erase("show_help") + + print(JSON.stringify(resolved, ' ')) + + for key in resolved: + resolved[key] = null + + print("\n\nAnd here's an empty config for you fill in what you want.") + print(JSON.stringify(resolved, ' ')) + + +func _run_tests(opt_resolver): + _final_opts = opt_resolver.get_resolved_values(); + _gut_config.options = _final_opts + + var runner = GutRunner.instantiate() + runner.set_gut_config(_gut_config) + get_tree().root.add_child(runner) + + if(opt_resolver.cmd_opts.raie): + runner.run_from_editor() + else: + runner.run_tests() + + +# parse options and run Gut +func main(): + var opt_resolver = OptionResolver.new() + opt_resolver.set_base_opts(_gut_config.default_options) + + var cli_opts = setup_options(_gut_config.default_options, _gut_config.valid_fonts) + + cli_opts.parse() + var all_options_valid = cli_opts.unused.size() == 0 + extract_command_line_options(cli_opts, opt_resolver.cmd_opts) + + var config_path = opt_resolver.get_value('config_file') + var load_result = 1 + # Checking for an empty config path allows us to not use a config file via + # the -gconfig_file option since using "-gconfig_file=" or -gconfig_file=''" + # will result in an empty string. + if(config_path != ''): + load_result = _gut_config.load_options_no_defaults(config_path) + + # SHORTCIRCUIT + if(!all_options_valid): + print('Unknown arguments: ', cli_opts.unused) + get_tree().quit(1) + elif(load_result == -1): + print('Invalid gutconfig ', load_result) + get_tree().quit(1) + else: + opt_resolver.config_opts = _gut_config.options + + if(cli_opts.get_value('-gh')): + print(GutUtils.version_numbers.get_version_text()) + cli_opts.print_help() + get_tree().quit(0) + elif(cli_opts.get_value('-gpo')): + print('All config options and where they are specified. ' + + 'The "final" value shows which value will actually be used ' + + 'based on order of precedence (default < .gutconfig < cmd line).' + "\n") + print(opt_resolver.to_s_verbose()) + get_tree().quit(0) + elif(cli_opts.get_value('-gprint_gutconfig_sample')): + _print_gutconfigs(opt_resolver.get_resolved_values()) + get_tree().quit(0) + else: + _run_tests(opt_resolver) + + + +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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/gut/cli/gut_cli.gd.uid b/addons/gut/cli/gut_cli.gd.uid new file mode 100644 index 0000000..d57070b --- /dev/null +++ b/addons/gut/cli/gut_cli.gd.uid @@ -0,0 +1 @@ +uid://bhuudqinp4bth diff --git a/addons/gut/cli/optparse.gd b/addons/gut/cli/optparse.gd new file mode 100644 index 0000000..75dec1c --- /dev/null +++ b/addons/gut/cli/optparse.gd @@ -0,0 +1,678 @@ +## Parses command line arguments, as one might expect. +## +## Parses command line arguments with a bunch of options including generating +## text that displays all the arguments your script accepts. This +## is included in the GUT ClassRef since it might be usable by others and is +## portable (everything it needs is in this one file). +## [br] +## This does alot, if you want to see it in action have a look at +## [url=https://github.com/bitwes/Gut/blob/main/scratch/optparse_example.gd]scratch/optparse_example.gd[/url] +## [codeblock lang=text] +## +## Godot Argument Lists +## ------------------------- +## There are two sets of command line arguments that Godot populates: +## OS.get_cmdline_args +## OS.get_cmdline_user_args. +## +## OS.get_cmdline_args contains any arguments that are not used by the engine +## itself. This means options like --help and -d will never appear in this list +## since these are used by the engine. The one exception is the -s option which +## is always included as the first entry and the script path as the second. +## Optparse ignores these values for argument processing but can be accessed +## with my_optparse.options.script_option. This list does not contain any +## arguments that appear in OS.get_cmdline_user_args. +## +## OS.get_cmdline_user_args contains any arguments that appear on the command +## line AFTER " -- " or " ++ ". This list CAN contain options that the engine +## would otherwise use, and are ignored completely by the engine. +## +## The parse method, by default, includes arguments from OS.get_cmdline_args and +## OS.get_cmdline_user_args. You can optionally pass one of these to the parse +## method to limit which arguments are parsed. You can also conjure up your own +## array of arguments and pass that to parse. +## +## See Godot's documentation for get_cmdline_args and get_cmdline_user_args for +## more information. +## +## +## Adding Options +## -------------- +## Use the following to add options to be parsed. These methods return the +## created Option instance. See that class above for more info. You can use +## the returned instance to get values, or use get_value/get_value_or_null. +## add("--name", "default", "Description goes here") +## add(["--name", "--aliases"], "default", "Description goes here") +## add_required(["--name", "--aliases"], "default", "Description goes here") +## add_positional("--name", "default", "Description goes here") +## add_positional_required("--name", "default", "Description goes here") +## +## get_value will return the value of the option or the default if it was not +## set. get_value_or_null will return the value of the option or null if it was +## not set. +## +## The Datatype for an option is determined from the default value supplied to +## the various add methods. Supported types are +## String +## Int +## Float +## Array of strings +## Boolean +## +## +## Value Parsing +## ------------- +## optparse uses option_name_prefix to differentiate between option names and +## values. Any argument that starts with this value will be treated as an +## argument name. The default is "-". Set this before calling parse if you want +## to change it. +## +## Values for options can be supplied on the command line with or without an "=": +## option=value # no space around "=" +## option value # a space between option and value w/o = +## There is no way to escape "=" at this time. +## +## Array options can be specified multiple times and/or set from a comma delimited +## list. +## -gdir=a,b +## -gdir c,d +## -gdir e +## Results in -gdir equaling [a, b, c, d, e]. There is no way to escape commas +## at this time. +## +## To specify an empty list via the command line follow the option with an equal +## sign +## -gdir= +## +## Boolean options will have thier value set to !default when they are supplied +## on the command line. Boolean options cannot have a value on the command line. +## They are either supplied or not. +## +## If a value is not an array and is specified multiple times on the command line +## then the last entry will be used as the value. +## +## Positional argument values are parsed after all named arguments are parsed. +## This means that other options can appear before, between, and after positional +## arguments. +## --foo=bar positional_0_value --disabled --bar foo positional_1_value --a_flag +## +## Anything that is not used by named or positional arguments will appear in the +## unused property. You can use this to detect unrecognized arguments or treat +## everything else provided as a list of things, or whatever you want. You can +## use is_option on the elements of unused (or whatever you want really) to see +## if optparse would treat it as an option name. +## +## Use get_missing_required_options to get an array of Option with all required +## options that were not found when parsing. +## +## The parsed_args property holds the list of arguments that were parsed. +## +## +## Help Generation +## --------------- +## You can call get_help to generate help text, or you can just call print_help +## and this will print it for you. +## +## Set the banner property to any text you want to appear before the usage and +## options sections. +## +## Options are printed in the order they are added. You can add a heading for +## different options sections with add_heading. +## add("--asdf", 1, "This will have no heading") +## add_heading("foo") +## add("--foo", false, "This will have the foo heading") +## add("--another_foo", 1.5, "This too.") +## add_heading("This is after foo") +## add("--bar", true, "You probably get it by now.") +## +## If you include "[default]" in the description of a option, then the help will +## substitue it with the default value. +## [/codeblock] + + +#------------------------------------------------------------------------------- +# Holds all the properties of a command line option +# +# value will return the default when it has not been set. +#------------------------------------------------------------------------------- +class Option: + var _has_been_set = false + var _value = null + # REMEMBER that when this option is an array, you have to set the value + # before you alter the contents of the array (append etc) or has_been_set + # will return false and it might not be used right. For example + # get_value_or_null will return null when you've actually changed the value. + var value = _value: + get: + return _value + + set(val): + _has_been_set = true + _value = val + + var option_name = '' + var default = null + var description = '' + var required = false + var aliases: Array[String] = [] + var show_in_help = true + + + func _init(name,default_value,desc=''): + option_name = name + default = default_value + description = desc + _value = default + + + func wrap_text(text, left_indent, max_length, wiggle_room=15): + var line_indent = str("\n", " ".repeat(left_indent + 1)) + var wrapped = '' + var position = 0 + var split_length = max_length + while(position < text.length()): + if(position > 0): + wrapped += line_indent + + var split_by = split_length + if(position + split_by + wiggle_room >= text.length()): + split_by = text.length() - position + else: + var min_space = text.rfind(' ', position + split_length) + var max_space = text.find(' ', position + split_length) + if(max_space <= position + split_length + wiggle_room): + split_by = max_space - position + else: + split_by = min_space - position + + wrapped += text.substr(position, split_by).lstrip(' ') + + if(position == 0): + split_length = max_length - left_indent + + position += split_by + + + return wrapped + + + + func to_s(min_space=0, wrap_length=100): + var line_indent = str("\n", " ".repeat(min_space + 1)) + var subbed_desc = description + if not aliases.is_empty(): + subbed_desc += "\naliases: " + ", ".join(aliases) + subbed_desc = subbed_desc.replace('[default]', str(default)) + subbed_desc = subbed_desc.replace("\n", line_indent) + + var final = str(option_name.rpad(min_space), ' ', subbed_desc) + if(wrap_length != -1): + final = wrap_text(final, min_space, wrap_length) + + return final + + + func has_been_set(): + return _has_been_set + + + + +#------------------------------------------------------------------------------- +# A struct for organizing options by a heading +#------------------------------------------------------------------------------- +class OptionHeading: + var options = [] + var display = 'default' + + + + +#------------------------------------------------------------------------------- +# Organizes options by order, heading, position. Also responsible for all +# help related text generation. +#------------------------------------------------------------------------------- +class Options: + var options = [] + var positional = [] + var default_heading = OptionHeading.new() + var script_option = Option.new('-s', '?', 'script option provided by Godot') + + var _options_by_name = {"--script": script_option, "-s": script_option} + var _options_by_heading = [default_heading] + var _cur_heading = default_heading + + + func add_heading(display): + var heading = OptionHeading.new() + heading.display = display + _cur_heading = heading + _options_by_heading.append(heading) + + + func add(option, aliases=null): + options.append(option) + _options_by_name[option.option_name] = option + _cur_heading.options.append(option) + + if aliases != null: + for a in aliases: + _options_by_name[a] = option + option.aliases.assign(aliases) + + + func add_positional(option): + positional.append(option) + _options_by_name[option.option_name] = option + + + func get_by_name(option_name): + var found_param = null + if(_options_by_name.has(option_name)): + found_param = _options_by_name[option_name] + + return found_param + + + func get_help_text(): + var longest = 0 + var text = "" + for i in range(options.size()): + if(options[i].option_name.length() > longest): + longest = options[i].option_name.length() + + for heading in _options_by_heading: + if(heading != default_heading): + text += str("\n", heading.display, "\n") + for option in heading.options: + if(option.show_in_help): + text += str(' ', option.to_s(longest + 2).replace("\n", "\n "), "\n") + + return text + + + func get_option_value_text(): + var text = "" + var i = 0 + for option in positional: + text += str(i, '. ', option.option_name, ' = ', option.value) + + if(!option.has_been_set()): + text += " (default)" + text += "\n" + i += 1 + + for option in options: + text += str(option.option_name, ' = ', option.value) + + if(!option.has_been_set()): + text += " (default)" + text += "\n" + return text + + + func print_option_values(): + print(get_option_value_text()) + + + func get_missing_required_options(): + var to_return = [] + for opt in options: + if(opt.required and !opt.has_been_set()): + to_return.append(opt) + + for opt in positional: + if(opt.required and !opt.has_been_set()): + to_return.append(opt) + + return to_return + + + func get_usage_text(): + var pos_text = "" + for opt in positional: + pos_text += str("[", opt.description, "] ") + + if(pos_text != ""): + pos_text += " [opts] " + + return " -s " + script_option.value + " [opts] " + pos_text + + + + +#------------------------------------------------------------------------------- +# +# optarse +# +#------------------------------------------------------------------------------- +## @ignore +var options := Options.new() +## Set the banner property to any text you want to appear before the usage and +## options sections when printing the options help. +var banner := '' +## optparse uses option_name_prefix to differentiate between option names and +## values. Any argument that starts with this value will be treated as an +## argument name. The default is "-". Set this before calling parse if you want +## to change it. +var option_name_prefix := '-' +## @ignore +var unused = [] +## @ignore +var parsed_args = [] +## @ignore +var values: Dictionary = {} + + +func _populate_values_dictionary(): + for entry in options.options: + var value_key = entry.option_name.lstrip('-') + values[value_key] = entry.value + + for entry in options.positional: + var value_key = entry.option_name.lstrip('-') + values[value_key] = entry.value + + +func _convert_value_to_array(raw_value): + var split = raw_value.split(',') + # This is what an empty set looks like from the command line. If we do + # not do this then we will always get back [''] which is not what it + # shoudl be. + if(split.size() == 1 and split[0] == ''): + split = [] + return split + +# REMEMBER raw_value not used for bools. +func _set_option_value(option, raw_value): + var t = typeof(option.default) + # only set values that were specified at the command line so that + # we can punch through default and config values correctly later. + # Without this check, you can't tell the difference between the + # defaults and what was specified, so you can't punch through + # higher level options. + if(t == TYPE_INT): + option.value = int(raw_value) + elif(t == TYPE_STRING): + option.value = str(raw_value) + elif(t == TYPE_ARRAY): + var values = _convert_value_to_array(raw_value) + if(!option.has_been_set()): + option.value = [] + option.value.append_array(values) + elif(t == TYPE_BOOL): + option.value = !option.default + elif(t == TYPE_FLOAT): + option.value = float(raw_value) + elif(t == TYPE_NIL): + print(option.option_name + ' cannot be processed, it has a nil datatype') + else: + print(option.option_name + ' cannot be processed, it has unknown datatype:' + str(t)) + + +func _parse_command_line_arguments(args): + var parsed_opts = args.duplicate() + var i = 0 + var positional_index = 0 + + while i < parsed_opts.size(): + var opt = '' + var value = '' + var entry = parsed_opts[i] + + if(is_option(entry)): + if(entry.find('=') != -1): + var parts = entry.split('=') + opt = parts[0] + value = parts[1] + var the_option = options.get_by_name(opt) + if(the_option != null): + parsed_opts.remove_at(i) + _set_option_value(the_option, value) + else: + i += 1 + else: + var the_option = options.get_by_name(entry) + if(the_option != null): + parsed_opts.remove_at(i) + if(typeof(the_option.default) == TYPE_BOOL): + _set_option_value(the_option, null) + elif(i < parsed_opts.size() and !is_option(parsed_opts[i])): + value = parsed_opts[i] + parsed_opts.remove_at(i) + _set_option_value(the_option, value) + else: + i += 1 + else: + if(positional_index < options.positional.size()): + _set_option_value(options.positional[positional_index], entry) + parsed_opts.remove_at(i) + positional_index += 1 + else: + i += 1 + + # this is the leftovers that were not extracted. + return parsed_opts + + +## Test if something is an existing argument. If [code]str(arg)[/code] begins +## with the [member option_name_prefix], it will considered true, +## otherwise it will be considered false. +func is_option(arg) -> bool: + return str(arg).begins_with(option_name_prefix) + + +## Adds a command line option. +## If [param op_names] is a String, this is set as the argument's name. +## If [param op_names] is an Array of Strings, all elements of the array +## will be aliases for the same argument and will be treated as such during +## parsing. +## [param default] is the default value the option will be set to if it is not +## explicitly set during parsing. +## [param desc] is a human readable text description of the option. +## If the option is successfully added, the Option object will be returned. +## If the option is not successfully added (e.g. a name collision with another +## option occurs), an error message will be printed and [code]null[/code] +## will be returned. +func add(op_names, default, desc: String) -> Option: + var op_name: String + var aliases: Array[String] = [] + var new_op: Option = null + + if(typeof(op_names) == TYPE_STRING): + op_name = op_names + else: + op_name = op_names[0] + aliases.assign(op_names.slice(1)) + + var bad_alias: int = aliases.map( + func (a: String) -> bool: return options.get_by_name(a) != null + ).find(true) + + if(options.get_by_name(op_name) != null): + push_error(str('Option [', op_name, '] already exists.')) + elif bad_alias != -1: + push_error(str('Option [', aliases[bad_alias], '] already exists.')) + else: + new_op = Option.new(op_name, default, desc) + options.add(new_op, aliases) + + return new_op + + +## Adds a required command line option. +## Required options that have not been set may be collected after parsing +## by calling [method get_missing_required_options]. +## If [param op_names] is a String, this is set as the argument's name. +## If [param op_names] is an Array of Strings, all elements of the array +## will be aliases for the same argument and will be treated as such during +## parsing. +## [param default] is the default value the option will be set to if it is not +## explicitly set during parsing. +## [param desc] is a human readable text description of the option. +## If the option is successfully added, the Option object will be returned. +## If the option is not successfully added (e.g. a name collision with another +## option occurs), an error message will be printed and [code]null[/code] +## will be returned. +func add_required(op_names, default, desc: String) -> Option: + var op := add(op_names, default, desc) + if(op != null): + op.required = true + return op + + +## Adds a positional command line option. +## Positional options are parsed by their position in the list of arguments +## are are not assigned by name by the user. +## If [param op_name] is a String, this is set as the argument's name. +## If [param op_name] is an Array of Strings, all elements of the array +## will be aliases for the same argument and will be treated as such during +## parsing. +## [param default] is the default value the option will be set to if it is not +## explicitly set during parsing. +## [param desc] is a human readable text description of the option. +## If the option is successfully added, the Option object will be returned. +## If the option is not successfully added (e.g. a name collision with another +## option occurs), an error message will be printed and [code]null[/code] +## will be returned. +func add_positional(op_name, default, desc: String) -> Option: + var new_op = null + if(options.get_by_name(op_name) != null): + push_error(str('Positional option [', op_name, '] already exists.')) + else: + new_op = Option.new(op_name, default, desc) + options.add_positional(new_op) + return new_op + + +## Adds a required positional command line option. +## If [param op_name] is a String, this is set as the argument's name. +## Required options that have not been set may be collected after parsing +## by calling [method get_missing_required_options]. +## Positional options are parsed by their position in the list of arguments +## are are not assigned by name by the user. +## If [param op_name] is an Array of Strings, all elements of the array +## will be aliases for the same argument and will be treated as such during +## parsing. +## [param default] is the default value the option will be set to if it is not +## explicitly set during parsing. +## [param desc] is a human readable text description of the option. +## If the option is successfully added, the Option object will be returned. +## If the option is not successfully added (e.g. a name collision with another +## option occurs), an error message will be printed and [code]null[/code] +## will be returned. +func add_positional_required(op_name, default, desc: String) -> Option: + var op = add_positional(op_name, default, desc) + if(op != null): + op.required = true + return op + + +## Headings are used to separate logical groups of command line options +## when printing out options from the help menu. +## Headings are printed out between option descriptions in the order +## that [method add_heading] was called. +func add_heading(display_text: String) -> void: + options.add_heading(display_text) + + +## Gets the value assigned to an option after parsing. +## [param name] can be the name of the option or an alias of it. +## [param name] specifies the option whose value you wish to query. +## If the option exists, the value assigned to it during parsing is returned. +## Otherwise, an error message is printed and [code]null[/code] is returned. +func get_value(name: String): + var found_param: Option = options.get_by_name(name) + + if(found_param != null): + return found_param.value + else: + push_error("COULD NOT FIND OPTION " + name) + return null + + +## Gets the value assigned to an option after parsing, +## returning null if the option was not assigned instead of its default value. +## [param name] specifies the option whose value you wish to query. +## This can be useful when providing an order of precedence to your values. +## For example if +## [codeblock] +## default value < config file < command line +## [/codeblock] +## then you do not want to get the default value for a command line option or +## it will overwrite the value in a config file. +func get_value_or_null(name: String): + var found_param: Option = options.get_by_name(name) + + if(found_param != null and found_param.has_been_set()): + return found_param.value + else: + return null + + +## Returns the help text for all defined options. +func get_help() -> String: + var sep := '---------------------------------------------------------' + + var text := str(sep, "\n", banner, "\n\n") + text += "Usage\n-----------\n" + text += " " + options.get_usage_text() + "\n\n" + text += "\nOptions\n-----------\n" + text += options.get_help_text() + text += str(sep, "\n") + return text + + +## Prints out the help text for all defined options. +func print_help() -> void: + print(get_help()) + + +## Parses a string for all options that have been set in this optparse. +## if [param cli_args] is passed as a String, then it is parsed. +## Otherwise if [param cli_args] is null, +## aruments passed to the Godot engine at startup are parsed. +## See the explanation at the top of addons/gut/cli/optparse.gd to understand +## which arguments this will have access to. +func parse(cli_args=null) -> void: + parsed_args = cli_args + + if(parsed_args == null): + parsed_args = OS.get_cmdline_args() + parsed_args.append_array(OS.get_cmdline_user_args()) + + unused = _parse_command_line_arguments(parsed_args) + _populate_values_dictionary() + + +## Get all options that were required and were not set during parsing. +## The return value is an Array of Options. +func get_missing_required_options() -> Array: + return options.get_missing_required_options() + + +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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. +# +# ############################################################################## \ No newline at end of file diff --git a/addons/gut/cli/optparse.gd.uid b/addons/gut/cli/optparse.gd.uid new file mode 100644 index 0000000..efcf3e1 --- /dev/null +++ b/addons/gut/cli/optparse.gd.uid @@ -0,0 +1 @@ +uid://c8m4fojwln6bq diff --git a/addons/gut/collected_script.gd b/addons/gut/collected_script.gd new file mode 100644 index 0000000..7b7324e --- /dev/null +++ b/addons/gut/collected_script.gd @@ -0,0 +1,208 @@ +# ------------------------------------------------------------------------------ +# This holds all the meta information for a test script. It contains the +# name of the inner class and an array of CollectedTests. This does not parse +# anything, it just holds the data about parsed scripts and tests. The +# TestCollector is responsible for populating this object. +# +# This class also facilitates all the exporting and importing of tests. +# ------------------------------------------------------------------------------ +var CollectedTest = GutUtils.CollectedTest + +var _lgr = null + +# One entry per test found in the script. Added externally by TestCollector +var tests = [] +# One entry for before_all and after_all (maybe add before_each and after_each). +# These are added by Gut when running before_all and after_all for the script. +var setup_teardown_tests = [] +var inner_class_name:StringName +var path:String + + +# Set externally by test_collector after it can verify that the script was +# actually loaded. This could probably be changed to just hold the GutTest +# script that was loaded, cutting down on complexity elsewhere. +var is_loaded = false + +# Set by Gut when it decides that a script should be skipped. +# Right now this is whenever the script has the variable skip_script declared. +# the value of skip_script is put into skip_reason. +var was_skipped = false +var skip_reason = '' +var was_run = false + + +var name = '' : + get: return path + set(val):pass + + +func _init(logger=null): + _lgr = logger + + +func get_new(): + var inst = load_script().new() + inst.collected_script = self + return inst + + +func load_script(): + var to_return = load(path) + + if(inner_class_name != null and inner_class_name != ''): + # If we wanted to do inner classes in inner classses + # then this would have to become some kind of loop or recursive + # call to go all the way down the chain or this class would + # have to change to hold onto the loaded class instead of + # just path information. + to_return = to_return.get(inner_class_name) + + return to_return + +# script.gd.InnerClass +func get_filename_and_inner(): + var to_return = get_filename() + if(inner_class_name != ''): + to_return += '.' + String(inner_class_name) + return to_return + + +# res://foo/bar.gd.FooBar +func get_full_name(): + var to_return = path + if(inner_class_name != ''): + to_return += '.' + String(inner_class_name) + return to_return + + +func get_filename(): + return path.get_file() + + +func has_inner_class(): + return inner_class_name != '' + + +# Note: although this no longer needs to export the inner_class names since +# they are pulled from metadata now, it is easier to leave that in +# so we don't have to cut the export down to unique script names. +func export_to(config_file, section): + config_file.set_value(section, 'path', path) + config_file.set_value(section, 'inner_class', inner_class_name) + var names = [] + for i in range(tests.size()): + names.append(tests[i].name) + config_file.set_value(section, 'tests', names) + + +func _remap_path(source_path): + var to_return = source_path + if(!FileAccess.file_exists(source_path)): + _lgr.debug('Checking for remap for: ' + source_path) + var remap_path = source_path.get_basename() + '.gd.remap' + if(FileAccess.file_exists(remap_path)): + var cf = ConfigFile.new() + cf.load(remap_path) + to_return = cf.get_value('remap', 'path') + else: + _lgr.warn('Could not find remap file ' + remap_path) + return to_return + + +func import_from(config_file, section): + path = config_file.get_value(section, 'path') + path = _remap_path(path) + # Null is an acceptable value, but you can't pass null as a default to + # get_value since it thinks you didn't send a default...then it spits + # out red text. This works around that. + var inner_name = config_file.get_value(section, 'inner_class', 'Placeholder') + if(inner_name != 'Placeholder'): + inner_class_name = inner_name + else: # just being explicit + inner_class_name = StringName("") + + +func get_test_named(test_name): + return GutUtils.search_array(tests, 'name', test_name) + + +func get_ran_test_count(): + var count = 0 + for t in tests: + if(t.was_run): + count += 1 + return count + + +func get_assert_count(): + var count = 0 + for t in tests: + count += t.pass_texts.size() + count += t.fail_texts.size() + for t in setup_teardown_tests: + count += t.pass_texts.size() + count += t.fail_texts.size() + return count + + +func get_pass_count(): + var count = 0 + for t in tests: + count += t.pass_texts.size() + for t in setup_teardown_tests: + count += t.pass_texts.size() + return count + + +func get_fail_count(): + var count = 0 + for t in tests: + count += t.fail_texts.size() + for t in setup_teardown_tests: + count += t.fail_texts.size() + return count + + +func get_pending_count(): + var count = 0 + for t in tests: + count += t.pending_texts.size() + return count + + +func get_passing_test_count(): + var count = 0 + for t in tests: + if(t.is_passing()): + count += 1 + return count + + +func get_failing_test_count(): + var count = 0 + for t in tests: + if(t.is_failing()): + count += 1 + return count + + +func get_risky_count(): + var count = 0 + if(was_skipped): + count = 1 + else: + for t in tests: + if(t.is_risky()): + count += 1 + return count + + +func to_s(): + var to_return = path + if(inner_class_name != null): + to_return += str('.', inner_class_name) + to_return += "\n" + for i in range(tests.size()): + to_return += str(' ', tests[i].to_s()) + return to_return diff --git a/addons/gut/collected_script.gd.uid b/addons/gut/collected_script.gd.uid new file mode 100644 index 0000000..ae2a94a --- /dev/null +++ b/addons/gut/collected_script.gd.uid @@ -0,0 +1 @@ +uid://bjjcnr1oqvag6 diff --git a/addons/gut/collected_test.gd b/addons/gut/collected_test.gd new file mode 100644 index 0000000..3e30a13 --- /dev/null +++ b/addons/gut/collected_test.gd @@ -0,0 +1,120 @@ +# ------------------------------------------------------------------------------ +# Used to keep track of info about each test ran. +# ------------------------------------------------------------------------------ +# the name of the function +var name = "" + +# flag to know if the name has been printed yet. Used by the logger. +var has_printed_name = false + +# the number of arguments the method has +var arg_count = 0 + +# the time it took to execute the test in seconds +var time_taken : float = 0 + +# The number of asserts in the test. Converted to a property for backwards +# compatibility. This now reflects the text sizes instead of being a value +# that can be altered externally. +var assert_count = 0 : + get: return pass_texts.size() + fail_texts.size() + set(val): pass + +# Converted to propety for backwards compatibility. This now cannot be set +# externally +var pending = false : + get: return is_pending() + set(val): pass + +# the line number when the test fails +var line_number = -1 + +# Set internally by Gut using whatever reason Gut wants to use to set this. +# Gut will skip these marked true and the test will be listed as risky. +var should_skip = false # -- Currently not used by GUT don't believe ^ + +var pass_texts = [] +var fail_texts = [] +var pending_texts = [] +var orphans = 0 + +var was_run = false + +var collected_script : WeakRef = null + + +func did_pass(): + return is_passing() + + +func add_fail(fail_text): + fail_texts.append(fail_text) + + +func add_pending(pending_text): + pending_texts.append(pending_text) + + +func add_pass(passing_text): + pass_texts.append(passing_text) + + +# must have passed an assert and not have any other status to be passing +func is_passing(): + return pass_texts.size() > 0 and fail_texts.size() == 0 and pending_texts.size() == 0 + + +# failing takes precedence over everything else, so any failures makes the +# test a failure. +func is_failing(): + return fail_texts.size() > 0 + + +# test is only pending if pending was called and the test is not failing. +func is_pending(): + return pending_texts.size() > 0 and fail_texts.size() == 0 + + +func is_risky(): + return should_skip or (was_run and !did_something()) + + +func did_something(): + return is_passing() or is_failing() or is_pending() + + +func get_status_text(): + var to_return = GutUtils.TEST_STATUSES.NO_ASSERTS + + if(should_skip): + to_return = GutUtils.TEST_STATUSES.SKIPPED + elif(!was_run): + to_return = GutUtils.TEST_STATUSES.NOT_RUN + elif(pending_texts.size() > 0): + to_return = GutUtils.TEST_STATUSES.PENDING + elif(fail_texts.size() > 0): + to_return = GutUtils.TEST_STATUSES.FAILED + elif(pass_texts.size() > 0): + to_return = GutUtils.TEST_STATUSES.PASSED + + return to_return + + +# Deprecated +func get_status(): + return get_status_text() + + +func to_s(): + var pad = ' ' + var to_return = str(name, "[", get_status_text(), "]\n") + + for i in range(fail_texts.size()): + to_return += str(pad, 'Fail: ', fail_texts[i]) + for i in range(pending_texts.size()): + to_return += str(pad, 'Pending: ', pending_texts[i], "\n") + for i in range(pass_texts.size()): + to_return += str(pad, 'Pass: ', pass_texts[i], "\n") + return to_return + + diff --git a/addons/gut/collected_test.gd.uid b/addons/gut/collected_test.gd.uid new file mode 100644 index 0000000..97778ed --- /dev/null +++ b/addons/gut/collected_test.gd.uid @@ -0,0 +1 @@ +uid://cl854f1m26a2a diff --git a/addons/gut/comparator.gd b/addons/gut/comparator.gd new file mode 100644 index 0000000..8510d67 --- /dev/null +++ b/addons/gut/comparator.gd @@ -0,0 +1,125 @@ +var _strutils = GutUtils.Strutils.new() +var _max_length = 100 +var _should_compare_int_to_float = true + +const MISSING = '|__missing__gut__compare__value__|' + + +func _cannot_compare_text(v1, v2): + return str('Cannot compare ', _strutils.types[typeof(v1)], ' with ', + _strutils.types[typeof(v2)], '.') + + +func _make_missing_string(text): + return '' + + +func _create_missing_result(v1, v2, text): + var to_return = null + var v1_str = format_value(v1) + var v2_str = format_value(v2) + + if(typeof(v1) == TYPE_STRING and v1 == MISSING): + v1_str = _make_missing_string(text) + to_return = GutUtils.CompareResult.new() + elif(typeof(v2) == TYPE_STRING and v2 == MISSING): + v2_str = _make_missing_string(text) + to_return = GutUtils.CompareResult.new() + + if(to_return != null): + to_return.summary = str(v1_str, ' != ', v2_str) + to_return.are_equal = false + + return to_return + + +func simple(v1, v2, missing_string=''): + var missing_result = _create_missing_result(v1, v2, missing_string) + if(missing_result != null): + return missing_result + + var result = GutUtils.CompareResult.new() + var cmp_str = null + var extra = '' + + var tv1 = typeof(v1) + var tv2 = typeof(v2) + + # print(tv1, '::', tv2, ' ', _strutils.types[tv1], '::', _strutils.types[tv2]) + if(_should_compare_int_to_float and [TYPE_INT, TYPE_FLOAT].has(tv1) and [TYPE_INT, TYPE_FLOAT].has(tv2)): + result.are_equal = v1 == v2 + elif([TYPE_STRING, TYPE_STRING_NAME].has(tv1) and [TYPE_STRING, TYPE_STRING_NAME].has(tv2)): + result.are_equal = v1 == v2 + elif(GutUtils.are_datatypes_same(v1, v2)): + result.are_equal = v1 == v2 + + if(typeof(v1) == TYPE_DICTIONARY or typeof(v1) == TYPE_ARRAY): + var sub_result = GutUtils.DiffTool.new(v1, v2, GutUtils.DIFF.DEEP) + result.summary = sub_result.get_short_summary() + if(!sub_result.are_equal): + extra = ".\n" + sub_result.get_short_summary() + else: + cmp_str = '!=' + result.are_equal = false + extra = str('. ', _cannot_compare_text(v1, v2)) + + cmp_str = get_compare_symbol(result.are_equal) + result.summary = str(format_value(v1), ' ', cmp_str, ' ', format_value(v2), extra) + + return result + + +func shallow(v1, v2): + var result = null + if(GutUtils.are_datatypes_same(v1, v2)): + if(typeof(v1) in [TYPE_ARRAY, TYPE_DICTIONARY]): + result = GutUtils.DiffTool.new(v1, v2, GutUtils.DIFF.DEEP) + else: + result = simple(v1, v2) + else: + result = simple(v1, v2) + + return result + + +func deep(v1, v2): + var result = null + + if(GutUtils.are_datatypes_same(v1, v2)): + if(typeof(v1) in [TYPE_ARRAY, TYPE_DICTIONARY]): + result = GutUtils.DiffTool.new(v1, v2, GutUtils.DIFF.DEEP) + else: + result = simple(v1, v2) + else: + result = simple(v1, v2) + + return result + + +func format_value(val, max_val_length=_max_length): + return _strutils.truncate_string(_strutils.type2str(val), max_val_length) + + +func compare(v1, v2, diff_type=GutUtils.DIFF.SIMPLE): + var result = null + if(diff_type == GutUtils.DIFF.SIMPLE): + result = simple(v1, v2) + elif(diff_type == GutUtils.DIFF.DEEP): + result = deep(v1, v2) + + return result + + +func get_should_compare_int_to_float(): + return _should_compare_int_to_float + + +func set_should_compare_int_to_float(should_compare_int_float): + _should_compare_int_to_float = should_compare_int_float + + +func get_compare_symbol(is_equal): + if(is_equal): + return '==' + else: + return '!=' diff --git a/addons/gut/comparator.gd.uid b/addons/gut/comparator.gd.uid new file mode 100644 index 0000000..10c63ff --- /dev/null +++ b/addons/gut/comparator.gd.uid @@ -0,0 +1 @@ +uid://bohry7fhscy7y diff --git a/addons/gut/compare_result.gd b/addons/gut/compare_result.gd new file mode 100644 index 0000000..1a8afb5 --- /dev/null +++ b/addons/gut/compare_result.gd @@ -0,0 +1,70 @@ +var _are_equal = false +var are_equal = false : + get: + return get_are_equal() + set(val): + set_are_equal(val) + +var _summary = null +var summary = null : + get: + return get_summary() + set(val): + set_summary(val) + +var _max_differences = 30 +var max_differences = 30 : + get: + return get_max_differences() + set(val): + set_max_differences(val) + +var _differences = {} +var differences : + get: + return get_differences() + set(val): + set_differences(val) + +func _block_set(which, val): + push_error(str('cannot set ', which, ', value [', val, '] ignored.')) + +func _to_string(): + return str(get_summary()) # could be null, gotta str it. + +func get_are_equal(): + return _are_equal + +func set_are_equal(r_eq): + _are_equal = r_eq + +func get_summary(): + return _summary + +func set_summary(smry): + _summary = smry + +func get_total_count(): + pass + +func get_different_count(): + pass + +func get_short_summary(): + return summary + +func get_max_differences(): + return _max_differences + +func set_max_differences(max_diff): + _max_differences = max_diff + +func get_differences(): + return _differences + +func set_differences(diffs): + _block_set('differences', diffs) + +func get_brackets(): + return null + diff --git a/addons/gut/compare_result.gd.uid b/addons/gut/compare_result.gd.uid new file mode 100644 index 0000000..aed99a4 --- /dev/null +++ b/addons/gut/compare_result.gd.uid @@ -0,0 +1 @@ +uid://cow1xqmqqvn4e diff --git a/addons/gut/diff_formatter.gd b/addons/gut/diff_formatter.gd new file mode 100644 index 0000000..05f898f --- /dev/null +++ b/addons/gut/diff_formatter.gd @@ -0,0 +1,63 @@ +var _strutils = GutUtils.Strutils.new() +const INDENT = ' ' +var _max_to_display = 30 +const ABSOLUTE_MAX_DISPLAYED = 10000 +const UNLIMITED = -1 + + +func _single_diff(diff, depth=0): + var to_return = "" + var brackets = diff.get_brackets() + + if(brackets != null and !diff.are_equal): + to_return = '' + to_return += str(brackets.open, "\n", + _strutils.indent_text(differences_to_s(diff.differences, depth), depth+1, INDENT), "\n", + brackets.close) + else: + to_return = str(diff) + + return to_return + + +func make_it(diff): + var to_return = '' + if(diff.are_equal): + to_return = diff.summary + else: + if(_max_to_display == ABSOLUTE_MAX_DISPLAYED): + to_return = str(diff.get_value_1(), ' != ', diff.get_value_2()) + else: + to_return = diff.get_short_summary() + to_return += str("\n", _strutils.indent_text(_single_diff(diff, 0), 1, ' ')) + return to_return + + +func differences_to_s(differences, depth=0): + var to_return = '' + var keys = differences.keys() + keys.sort() + var limit = min(_max_to_display, differences.size()) + + for i in range(limit): + var key = keys[i] + to_return += str(key, ": ", _single_diff(differences[key], depth)) + + if(i != limit -1): + to_return += "\n" + + if(differences.size() > _max_to_display): + to_return += str("\n\n... ", differences.size() - _max_to_display, " more.") + + return to_return + + +func get_max_to_display(): + return _max_to_display + + +func set_max_to_display(max_to_display): + _max_to_display = max_to_display + if(_max_to_display == UNLIMITED): + _max_to_display = ABSOLUTE_MAX_DISPLAYED + diff --git a/addons/gut/diff_formatter.gd.uid b/addons/gut/diff_formatter.gd.uid new file mode 100644 index 0000000..5662859 --- /dev/null +++ b/addons/gut/diff_formatter.gd.uid @@ -0,0 +1 @@ +uid://ch2km05phxacd diff --git a/addons/gut/diff_tool.gd b/addons/gut/diff_tool.gd new file mode 100644 index 0000000..d1b5091 --- /dev/null +++ b/addons/gut/diff_tool.gd @@ -0,0 +1,156 @@ +extends 'res://addons/gut/compare_result.gd' +const INDENT = ' ' +enum { + DEEP, + SIMPLE +} + +var _strutils = GutUtils.Strutils.new() +var _compare = GutUtils.Comparator.new() + +var _value_1 = null +var _value_2 = null +var _total_count = 0 +var _diff_type = null +var _brackets = null +var _valid = true +var _desc_things = 'somethings' + +# -------- comapre_result.gd "interface" --------------------- +func set_are_equal(val): + _block_set('are_equal', val) + +func get_are_equal(): + if(!_valid): + return null + else: + return differences.size() == 0 + + +func set_summary(val): + _block_set('summary', val) + +func get_summary(): + return summarize() + +func get_different_count(): + return differences.size() + +func get_total_count(): + return _total_count + +func get_short_summary(): + var text = str(_strutils.truncate_string(str(_value_1), 50), + ' ', _compare.get_compare_symbol(are_equal), ' ', + _strutils.truncate_string(str(_value_2), 50)) + if(!are_equal): + text += str(' ', get_different_count(), ' of ', get_total_count(), + ' ', _desc_things, ' do not match.') + return text + +func get_brackets(): + return _brackets +# -------- comapre_result.gd "interface" --------------------- + + +func _invalidate(): + _valid = false + differences = null + + +func _init(v1,v2,diff_type=DEEP): + _value_1 = v1 + _value_2 = v2 + _diff_type = diff_type + _compare.set_should_compare_int_to_float(false) + _find_differences(_value_1, _value_2) + + +func _find_differences(v1, v2): + if(GutUtils.are_datatypes_same(v1, v2)): + if(typeof(v1) == TYPE_ARRAY): + _brackets = {'open':'[', 'close':']'} + _desc_things = 'indexes' + _diff_array(v1, v2) + elif(typeof(v2) == TYPE_DICTIONARY): + _brackets = {'open':'{', 'close':'}'} + _desc_things = 'keys' + _diff_dictionary(v1, v2) + else: + _invalidate() + GutUtils.get_logger().error('Only Arrays and Dictionaries are supported.') + else: + _invalidate() + GutUtils.get_logger().error('Only Arrays and Dictionaries are supported.') + + +func _diff_array(a1, a2): + _total_count = max(a1.size(), a2.size()) + for i in range(a1.size()): + var result = null + if(i < a2.size()): + if(_diff_type == DEEP): + result = _compare.deep(a1[i], a2[i]) + else: + result = _compare.simple(a1[i], a2[i]) + else: + result = _compare.simple(a1[i], _compare.MISSING, 'index') + + if(!result.are_equal): + differences[i] = result + + if(a1.size() < a2.size()): + for i in range(a1.size(), a2.size()): + differences[i] = _compare.simple(_compare.MISSING, a2[i], 'index') + + +func _diff_dictionary(d1, d2): + var d1_keys = d1.keys() + var d2_keys = d2.keys() + + # Process all the keys in d1 + _total_count += d1_keys.size() + for key in d1_keys: + if(!d2.has(key)): + differences[key] = _compare.simple(d1[key], _compare.MISSING, 'key') + else: + d2_keys.remove_at(d2_keys.find(key)) + + var result = null + if(_diff_type == DEEP): + result = _compare.deep(d1[key], d2[key]) + else: + result = _compare.simple(d1[key], d2[key]) + + if(!result.are_equal): + differences[key] = result + + # Process all the keys in d2 that didn't exist in d1 + _total_count += d2_keys.size() + for i in range(d2_keys.size()): + differences[d2_keys[i]] = _compare.simple(_compare.MISSING, d2[d2_keys[i]], 'key') + + +func summarize(): + var summary = '' + + if(are_equal): + summary = get_short_summary() + else: + var formatter = load('res://addons/gut/diff_formatter.gd').new() + formatter.set_max_to_display(max_differences) + summary = formatter.make_it(self) + + return summary + + +func get_diff_type(): + return _diff_type + + +func get_value_1(): + return _value_1 + + +func get_value_2(): + return _value_2 diff --git a/addons/gut/diff_tool.gd.uid b/addons/gut/diff_tool.gd.uid new file mode 100644 index 0000000..1e49604 --- /dev/null +++ b/addons/gut/diff_tool.gd.uid @@ -0,0 +1 @@ +uid://beoxokvl1hjs8 diff --git a/addons/gut/double_templates/function_template.txt b/addons/gut/double_templates/function_template.txt new file mode 100644 index 0000000..ad4f8ed --- /dev/null +++ b/addons/gut/double_templates/function_template.txt @@ -0,0 +1,9 @@ +{func_decleration} + if(__gutdbl == null): + return + + __gutdbl.spy_on('{method_name}', {param_array}) + if(__gutdbl.is_stubbed_to_call_super('{method_name}', {param_array})): + return {super_call} + else: + return await __gutdbl.handle_other_stubs('{method_name}', {param_array}) diff --git a/addons/gut/double_templates/init_template.txt b/addons/gut/double_templates/init_template.txt new file mode 100644 index 0000000..9b43474 --- /dev/null +++ b/addons/gut/double_templates/init_template.txt @@ -0,0 +1,4 @@ +{func_decleration}: + super({super_params}) + __gutdbl.spy_on('{method_name}', {param_array}) + diff --git a/addons/gut/double_templates/script_template.txt b/addons/gut/double_templates/script_template.txt new file mode 100644 index 0000000..a593c8e --- /dev/null +++ b/addons/gut/double_templates/script_template.txt @@ -0,0 +1,37 @@ +# ############################################################################## +# Gut Doubled Script +# ############################################################################## +{extends} + +{constants} + +{properties} + +# ------------------------------------------------------------------------------ +# GUT stuff +# ------------------------------------------------------------------------------ +var __gutdbl_values = { + thepath = '{path}', + subpath = '{subpath}', + stubber = {stubber_id}, + spy = {spy_id}, + gut = {gut_id}, + from_singleton = '{singleton_name}', + is_partial = {is_partial}, + doubled_methods = {doubled_methods}, +} +var __gutdbl = load('res://addons/gut/double_tools.gd').new(self) + +# Here so other things can check for a method to know if this is a double. +func __gutdbl_check_method__(): + pass + +# Cleanup called by GUT after tests have finished. Important for RefCounted +# objects. Nodes are freed, and won't have this method called on them. +func __gutdbl_done(): + __gutdbl = null + __gutdbl_values.clear() + +# ------------------------------------------------------------------------------ +# Doubled Methods +# ------------------------------------------------------------------------------ diff --git a/addons/gut/double_tools.gd b/addons/gut/double_tools.gd new file mode 100644 index 0000000..8153a55 --- /dev/null +++ b/addons/gut/double_tools.gd @@ -0,0 +1,70 @@ +var thepath = '' +var subpath = '' +var from_singleton = null +var is_partial = null + +var double_ref : WeakRef = null +var stubber_ref : WeakRef = null +var spy_ref : WeakRef = null +var gut_ref : WeakRef = null + +const NO_DEFAULT_VALUE = '!__gut__no__default__value__!' +func _init(double = null): + if(double != null): + var values = double.__gutdbl_values + double_ref = weakref(double) + thepath = values.thepath + subpath = values.subpath + stubber_ref = weakref_from_id(values.stubber) + spy_ref = weakref_from_id(values.spy) + gut_ref = weakref_from_id(values.gut) + from_singleton = values.from_singleton + is_partial = values.is_partial + + if(gut_ref.get_ref() != null): + gut_ref.get_ref().get_autofree().add_free(double_ref.get_ref()) + + +func _get_stubbed_method_to_call(method_name, called_with): + var method = stubber_ref.get_ref().get_call_this(double_ref.get_ref(), method_name, called_with) + if(method != null): + method = method.bindv(called_with) + return method + return method + + +func weakref_from_id(inst_id): + if(inst_id == -1): + return weakref(null) + else: + return weakref(instance_from_id(inst_id)) + + +func is_stubbed_to_call_super(method_name, called_with): + if(stubber_ref.get_ref() != null): + return stubber_ref.get_ref().should_call_super(double_ref.get_ref(), method_name, called_with) + else: + return false + + +func handle_other_stubs(method_name, called_with): + if(stubber_ref.get_ref() == null): + return + + var method = _get_stubbed_method_to_call(method_name, called_with) + if(method != null): + return await method.call() + else: + return stubber_ref.get_ref().get_return(double_ref.get_ref(), method_name, called_with) + + +func spy_on(method_name, called_with): + if(spy_ref.get_ref() != null): + spy_ref.get_ref().add_call(double_ref.get_ref(), method_name, called_with) + + +func default_val(method_name, p_index): + if(stubber_ref.get_ref() == null): + return null + else: + return stubber_ref.get_ref().get_default_value(double_ref.get_ref(), method_name, p_index) diff --git a/addons/gut/double_tools.gd.uid b/addons/gut/double_tools.gd.uid new file mode 100644 index 0000000..0113fa6 --- /dev/null +++ b/addons/gut/double_tools.gd.uid @@ -0,0 +1 @@ +uid://tr4khoco1hef diff --git a/addons/gut/doubler.gd b/addons/gut/doubler.gd new file mode 100644 index 0000000..5053c65 --- /dev/null +++ b/addons/gut/doubler.gd @@ -0,0 +1,312 @@ +extends RefCounted + + +var _base_script_text = GutUtils.get_file_as_text('res://addons/gut/double_templates/script_template.txt') +var _script_collector = GutUtils.ScriptCollector.new() +# used by tests for debugging purposes. +var print_source = false +var inner_class_registry = GutUtils.InnerClassRegistry.new() + +# ############### +# Properties +# ############### +var _stubber = GutUtils.Stubber.new() +func get_stubber(): + return _stubber +func set_stubber(stubber): + _stubber = stubber + +var _lgr = GutUtils.get_logger() +func get_logger(): + return _lgr +func set_logger(logger): + _lgr = logger + _method_maker.set_logger(logger) + +var _spy = null +func get_spy(): + return _spy +func set_spy(spy): + _spy = spy + +var _gut = null +func get_gut(): + return _gut +func set_gut(gut): + _gut = gut + +var _strategy = null +func get_strategy(): + return _strategy +func set_strategy(strategy): + if(GutUtils.DOUBLE_STRATEGY.values().has(strategy)): + _strategy = strategy + else: + _lgr.error(str('doubler.gd: invalid double strategy ', strategy)) + + +var _method_maker = GutUtils.MethodMaker.new() +func get_method_maker(): + return _method_maker + +var _ignored_methods = GutUtils.OneToMany.new() +func get_ignored_methods(): + return _ignored_methods + +# ############### +# Private +# ############### +func _init(strategy=GutUtils.DOUBLE_STRATEGY.SCRIPT_ONLY): + set_logger(GutUtils.get_logger()) + _strategy = strategy + + +func _get_indented_line(indents, text): + var to_return = '' + for _i in range(indents): + to_return += "\t" + return str(to_return, text, "\n") + + +func _stub_to_call_super(parsed, method_name): + if(!parsed.get_method(method_name).is_eligible_for_doubling()): + return + + var params = GutUtils.StubParams.new(parsed.script_path, method_name, parsed.subpath) + params.to_call_super() + _stubber.add_stub(params) + + +func _get_base_script_text(parsed, override_path, partial, included_methods): + var path = parsed.script_path + if(override_path != null): + path = override_path + + var stubber_id = -1 + if(_stubber != null): + stubber_id = _stubber.get_instance_id() + + var spy_id = -1 + if(_spy != null): + spy_id = _spy.get_instance_id() + + var gut_id = -1 + if(_gut != null): + gut_id = _gut.get_instance_id() + + var extends_text = parsed.get_extends_text() + + var values = { + # Top sections + "extends":extends_text, + "constants":'',#obj_info.get_constants_text(), + "properties":'',#obj_info.get_properties_text(), + + # metadata values + "path":path, + "subpath":GutUtils.nvl(parsed.subpath, ''), + "stubber_id":stubber_id, + "spy_id":spy_id, + "gut_id":gut_id, + "singleton_name":'',#GutUtils.nvl(obj_info.get_singleton_name(), ''), + "is_partial":partial, + "doubled_methods":included_methods, + } + + return _base_script_text.format(values) + + +func _is_method_eligible_for_doubling(parsed_script, parsed_method): + return !parsed_method.is_accessor() and \ + parsed_method.is_eligible_for_doubling() and \ + !_ignored_methods.has(parsed_script.resource, parsed_method.meta.name) + + +# Disable the native_method_override setting so that doubles do not generate +# errors or warnings when doubling with INCLUDE_NATIVE or when a method has +# been added because of param_count stub. +func _create_script_no_warnings(src): + var prev_native_override_value = null + var native_method_override = 'debug/gdscript/warnings/native_method_override' + prev_native_override_value = ProjectSettings.get_setting(native_method_override) + ProjectSettings.set_setting(native_method_override, 0) + + var DblClass = GutUtils.create_script_from_source(src) + + ProjectSettings.set_setting(native_method_override, prev_native_override_value) + return DblClass + + +func _create_double(parsed, strategy, override_path, partial): + var dbl_src = "" + var included_methods = [] + + for method in parsed.get_local_methods(): + if(_is_method_eligible_for_doubling(parsed, method)): + included_methods.append(method.meta.name) + dbl_src += _get_func_text(method.meta) + + if(strategy == GutUtils.DOUBLE_STRATEGY.INCLUDE_NATIVE): + for method in parsed.get_super_methods(): + if(_is_method_eligible_for_doubling(parsed, method)): + included_methods.append(method.meta.name) + _stub_to_call_super(parsed, method.meta.name) + dbl_src += _get_func_text(method.meta) + + var base_script = _get_base_script_text(parsed, override_path, partial, included_methods) + dbl_src = base_script + "\n\n" + dbl_src + + if(print_source): + var to_print :String = GutUtils.add_line_numbers(dbl_src) + to_print = to_print.rstrip("\n") + _lgr.log(str(to_print)) + + var DblClass = _create_script_no_warnings(dbl_src) + if(_stubber != null): + _stub_method_default_values(DblClass, parsed, strategy) + + if(print_source): + _lgr.log(str(" path | ", DblClass.resource_path, "\n")) + + return DblClass + + +func _stub_method_default_values(which, parsed, strategy): + for method in parsed.get_local_methods(): + if(method.is_eligible_for_doubling() and !_ignored_methods.has(parsed.resource, method.meta.name)): + _stubber.stub_defaults_from_meta(parsed.script_path, method.meta) + + +func _double_scene_and_script(scene, strategy, partial): + var dbl_bundle = scene._bundled.duplicate(true) + var script_obj = GutUtils.get_scene_script_object(scene) + # I'm not sure if the script object for the root node of a packed scene is + # always the first entry in "variants" so this tries to find it. + var script_index = dbl_bundle["variants"].find(script_obj) + var script_dbl = null + + if(script_obj != null): + if(partial): + script_dbl = _partial_double(script_obj, strategy, scene.get_path()) + else: + script_dbl = _double(script_obj, strategy, scene.get_path()) + + if(script_index != -1): + dbl_bundle["variants"][script_index] = script_dbl + + var doubled_scene = PackedScene.new() + doubled_scene._set_bundled_scene(dbl_bundle) + + return doubled_scene + + +func _get_inst_id_ref_str(inst): + var ref_str = 'null' + if(inst): + ref_str = str('instance_from_id(', inst.get_instance_id(),')') + return ref_str + + +func _get_func_text(method_hash): + return _method_maker.get_function_text(method_hash) + "\n" + + +func _parse_script(obj): + var parsed = null + + if(GutUtils.is_inner_class(obj)): + if(inner_class_registry.has(obj)): + parsed = _script_collector.parse(inner_class_registry.get_base_resource(obj), obj) + else: + _lgr.error('Doubling Inner Classes requires you register them first. Call register_inner_classes passing the script that contains the inner class.') + else: + parsed = _script_collector.parse(obj) + + return parsed + + +# Override path is used with scenes. +func _double(obj, strategy, override_path=null): + var parsed = _parse_script(obj) + if(parsed != null): + return _create_double(parsed, strategy, override_path, false) + + +func _partial_double(obj, strategy, override_path=null): + var parsed = _parse_script(obj) + if(parsed != null): + return _create_double(parsed, strategy, override_path, true) + + +# ------------------------- +# Public +# ------------------------- + +# double a script/object +func double(obj, strategy=_strategy): + return _double(obj, strategy) + +func partial_double(obj, strategy=_strategy): + return _partial_double(obj, strategy) + + +# double a scene +func double_scene(scene, strategy=_strategy): + return _double_scene_and_script(scene, strategy, false) + + +func partial_double_scene(scene, strategy=_strategy): + return _double_scene_and_script(scene, strategy, true) + + +func double_gdnative(which): + return _double(which, GutUtils.DOUBLE_STRATEGY.INCLUDE_NATIVE) + + +func partial_double_gdnative(which): + return _partial_double(which, GutUtils.DOUBLE_STRATEGY.INCLUDE_NATIVE) + + +func double_inner(parent, inner, strategy=_strategy): + var parsed = _script_collector.parse(parent, inner) + return _create_double(parsed, strategy, null, false) + + +func partial_double_inner(parent, inner, strategy=_strategy): + var parsed = _script_collector.parse(parent, inner) + return _create_double(parsed, strategy, null, true) + + +func add_ignored_method(obj, method_name): + _ignored_methods.add(obj, method_name) + + + +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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/gut/doubler.gd.uid b/addons/gut/doubler.gd.uid new file mode 100644 index 0000000..abcc296 --- /dev/null +++ b/addons/gut/doubler.gd.uid @@ -0,0 +1 @@ +uid://cpy013l0wqwmg diff --git a/addons/gut/dynamic_gdscript.gd b/addons/gut/dynamic_gdscript.gd new file mode 100644 index 0000000..2ce7667 --- /dev/null +++ b/addons/gut/dynamic_gdscript.gd @@ -0,0 +1,33 @@ +@tool +var default_script_name_no_extension = 'gut_dynamic_script' +var default_script_resource_path = 'res://addons/gut/not_a_real_file/' +var default_script_extension = "gd" + +var _created_script_count = 0 + + +# Creates a loaded script from the passed in source. This loaded script is +# returned unless there is an error. When an error occcurs the error number +# is returned instead. +func create_script_from_source(source, override_path=null): + _created_script_count += 1 + var r_path = str(default_script_resource_path, + default_script_name_no_extension, '_', _created_script_count, ".", + default_script_extension) + + if(override_path != null): + r_path = override_path + + var DynamicScript = GDScript.new() + DynamicScript.source_code = source.dedent() + # The resource_path must be unique or Godot thinks it is trying + # to load something it has already loaded and generates an error like + # ERROR: Another resource is loaded from path 'workaround for godot + # issue #65263' (possible cyclic resource inclusion). + DynamicScript.resource_path = r_path + var result = DynamicScript.reload() + if(result != OK): + DynamicScript = result + + return DynamicScript + diff --git a/addons/gut/dynamic_gdscript.gd.uid b/addons/gut/dynamic_gdscript.gd.uid new file mode 100644 index 0000000..3c64ffa --- /dev/null +++ b/addons/gut/dynamic_gdscript.gd.uid @@ -0,0 +1 @@ +uid://cnbjsrik0p5uf diff --git a/addons/gut/editor_caret_context_notifier.gd b/addons/gut/editor_caret_context_notifier.gd new file mode 100644 index 0000000..07bf380 --- /dev/null +++ b/addons/gut/editor_caret_context_notifier.gd @@ -0,0 +1,207 @@ +@tool +extends Node +# ############################################################################## +# +# Watches script editors and emits a signal whenever the method, inner class, +# or script changes based on cursor position and other stuff. +# +# Basically, whenever this thing's signal is emitted, then the RunAtCursor +# buttons should be updated to match the data passed to the signal. +# ############################################################################## +# In the editor, whenever a script is opened you get these new things that +# hang off of EditorInterface.get_script_editor() +# * ScriptEditorBase +# * CodeEdit +# ############################################################################## + + +var _last_info : Dictionary = {} +var _last_line = -1 +# This is the control that holds all the individual editors. +var _current_script_editor : ScriptEditor = null +# Reference to the GDScript for the last script we were notified about. +var _current_script = null +var _current_script_is_test_script = false +var _current_editor_base : ScriptEditorBase = null +var _current_editor : CodeEdit = null +# Quick lookup of editors based on the current script. +var _editors_for_scripts : Dictionary= {} + + +# In order to keep the data that comes back from the emitted signal way more +# usable, we have to know what GUT looks for for an inner-test-class prefix. +# If we didn't do this, then this thing would have to return all the inner +# classes and then we'd have to determine if we were in an inner-test-class +# outside of here by traversing all the classes returned. It makes this thing +# less generic and know too much, but this is probably already too generic as +# it is. +var inner_class_prefix = "Test" +var method_prefix = "test_" +var script_prefix = "test_" +var script_suffix = ".gd" + + +# Based on cursor and open editors, this will be emitted. You do what you +# want with it. +signal it_changed(change_data) + + +func _ready(): + # This will not change, and should not change, over the course of a session. + _current_script_editor = EditorInterface.get_script_editor() + _current_script_editor.editor_script_changed.connect(_on_editor_script_changed) + _current_script_editor.script_close.connect(_on_editor_script_close) + + +func _handle_caret_location(which): + var current_line = which.get_caret_line(0) + 1 + if(_last_line != current_line): + _last_line = current_line + + if(_current_script_is_test_script): + var new_info = _make_info(which, _current_script, _current_script_is_test_script) + if(_last_info != new_info): + _last_info = new_info + it_changed.emit(_last_info.duplicate()) + + +func _get_func_name_from_line(text): + text = text.strip_edges() + var left = text.split("(")[0] + var func_name = left.split(" ")[1] + return func_name + + +func _get_class_name_from_line(text): + text = text.strip_edges() + var right = text.split(" ")[1] + var the_name = right.rstrip(":") + return the_name + + +func _make_info(editor, script, test_script_flag): + if(editor == null): + return + + var info = { + script = script, + inner_class = null, + method = null, + is_test_script = test_script_flag + } + + var start_line = editor.get_caret_line() + var line = start_line + var done_func = false + var done_inner = false + while(line > 0 and (!done_func or !done_inner)): + if(editor.can_fold_line(line)): + var text = editor.get_line(line) + var strip_text = text.strip_edges(true, false) # only left + + if(!done_func and strip_text.begins_with("func ")): + info.method = _get_func_name_from_line(text) + done_func = true + # If the func line is left justified then there won't be any + # inner classes above it. + if(editor.get_indent_level(line) == 0): + done_inner = true + + if(!done_inner and strip_text.begins_with("class")): + var inner_name = _get_class_name_from_line(text) + # See note about inner_class_prefix, this knows too much, but + # if it was to know less it would insanely more difficult + # everywhere. + if(inner_name.begins_with(inner_class_prefix)): + info.inner_class = inner_name + done_inner = true + done_func = true + line -= 1 + + # print('parsed lines: ', start_line - line, '(', info.inner_class, ':', info.method, ')') + return info +# ------------- +# Events +# ------------- + +# Fired whenever the script changes. This does not fire if you select something +# other than a script from the tree. So if you click a help file and then +# back to the same file, then this will fire for the same script +# +# This can fire multiple times for the same script when a script is opened. +func _on_editor_script_changed(script): + _last_line = -1 + _current_script = script + _current_editor_base = _current_script_editor.get_current_editor() + if(_current_editor_base.get_base_editor() is CodeEdit): + _current_editor = _current_editor_base.get_base_editor() + if(!_current_editor.caret_changed.is_connected(_on_caret_changed)): + _current_editor.caret_changed.connect(_on_caret_changed.bind(_current_editor)) + else: + _current_editor = null + _editors_for_scripts[script] = _current_editor + _current_script_is_test_script = is_test_script(_current_script) + + _handle_caret_location(_current_editor) + + +func _on_editor_script_close(script): + var script_editor = _editors_for_scripts.get(script, null) + if(script_editor != null): + if(script_editor.caret_changed.is_connected(_on_caret_changed)): + script_editor.caret_changed.disconnect(_on_caret_changed) + _editors_for_scripts.erase(script) + + +func _on_caret_changed(which): + # Sometimes this is fired for editors that are not the current. I could + # make this fire by saving a file in an external editor. I was unable to + # get useful data out when it wasn't the current editor so I'm only doing + # anything when it is the current editor. + if(which == _current_editor): + _handle_caret_location(which) + + +func _could_be_test_script(script): + return script.resource_path.get_file().begins_with(script_prefix) and \ + script.resource_path.get_file().ends_with(script_suffix) + +# ------------- +# Public +# ------------- +var _scripts_that_have_been_warned_about = [] +var _we_have_warned_enough = false +var _max_warnings = 5 +func is_test_script(script): + var base = script.get_base_script() + if(base == null and script.get_script_method_list().size() == 0 and _could_be_test_script(script)): + if(OS.is_stdout_verbose() or (!_scripts_that_have_been_warned_about.has(script.resource_path) and !_we_have_warned_enough)): + _scripts_that_have_been_warned_about.append(script.resource_path) + push_warning(str('[GUT] Treating ', script.resource_path, " as test script: ", + "GUT was not able to retrieve information about this script. If this is ", + "a new script you can ignore this warning. Otherwise, this may ", + "have to do with having VSCode open. Restarting Godot sometimes helps. See ", + "https://github.com/bitwes/Gut/issues/754")) + if(!OS.is_stdout_verbose() and _scripts_that_have_been_warned_about.size() >= _max_warnings): + print("[GUT] Disabling warning.") + _we_have_warned_enough = true + + # We can't know if this is a test script. It's more usable if we + # assume this is a test script. + return true + else: + while(base and base.resource_path != 'res://addons/gut/test.gd'): + base = base.get_base_script() + return base != null + + +func get_info(): + return _last_info.duplicate() + + +func log_values(): + print("---------------------------------------------------------------") + print("script ", _current_script) + print("script_editor ", _current_script_editor) + print("editor_base ", _current_editor_base) + print("editor ", _current_editor) diff --git a/addons/gut/editor_caret_context_notifier.gd.uid b/addons/gut/editor_caret_context_notifier.gd.uid new file mode 100644 index 0000000..22cec65 --- /dev/null +++ b/addons/gut/editor_caret_context_notifier.gd.uid @@ -0,0 +1 @@ +uid://c110s7a32x4su diff --git a/addons/gut/error_tracker.gd b/addons/gut/error_tracker.gd new file mode 100644 index 0000000..5e82b10 --- /dev/null +++ b/addons/gut/error_tracker.gd @@ -0,0 +1,193 @@ +extends Logger +class_name GutErrorTracker + +# ------------------------------------------------------------------------------ +# Static methods wrap around add/remove logger to make disabling the logger +# easier and to help avoid misusing add/remove in tests. If GUT needs to +# add/remove a logger then this is how it should do it. +# ------------------------------------------------------------------------------ +static var registered_loggers := {} +static var register_loggers = true + +static func register_logger(which): + if(register_loggers and !registered_loggers.has(which)): + OS.add_logger(which) + registered_loggers[which] = get_stack() + + +static func deregister_logger(which): + if(registered_loggers.has(which)): + OS.remove_logger(which) + registered_loggers.erase(which) + + + + +# ------------------------------------------------------------------------------ +# GutErrorTracker +# ------------------------------------------------------------------------------ +var _current_test_id = GutUtils.NO_TEST +var _mutex = Mutex.new() + +var errors = GutUtils.OneToMany.new() + +var treat_gut_errors_as : GutUtils.TREAT_AS = GutUtils.TREAT_AS.FAILURE +var treat_engine_errors_as : GutUtils.TREAT_AS = GutUtils.TREAT_AS.FAILURE +var treat_push_error_as : GutUtils.TREAT_AS = GutUtils.TREAT_AS.FAILURE +var disabled = false + + +# ---------------- +#region Private +# ---------------- + +func _get_stack_data(current_test_name): + var test_entry = {} + var stackTrace = get_stack() + + if(stackTrace!=null): + var index = 0 + while(index < stackTrace.size() and test_entry == {}): + var line = stackTrace[index] + var function = line.get("function") + if function == current_test_name: + test_entry = stackTrace[index] + else: + index += 1 + + for i in range(index): + stackTrace.remove_at(0) + + return { + "test_entry" = test_entry, + "full_stack" = stackTrace + } + + +func _is_error_failable(error : GutTrackedError): + var is_it = false + if(error.handled == false): + if(error.is_gut_error()): + is_it = treat_gut_errors_as == GutUtils.TREAT_AS.FAILURE + elif(error.is_push_error()): + is_it = treat_push_error_as == GutUtils.TREAT_AS.FAILURE + elif(error.is_engine_error()): + is_it = treat_engine_errors_as == GutUtils.TREAT_AS.FAILURE + return is_it + +# ---------------- +#endregion +#region Godot's Logger Overrides +# ---------------- + +# Godot's Logger virtual method for errors +func _log_error(function: String, file: String, line: int, + code: String, rationale: String, editor_notify: bool, + error_type: int, script_backtraces: Array[ScriptBacktrace]) -> void: + + add_error(function, file, line, + code, rationale, editor_notify, + error_type, script_backtraces) + +# Godot's Logger virtual method for any output? +# func _log_message(message: String, error: bool) -> void: +# pass + +# ---------------- +#endregion +#region Public +# ---------------- + +func start_test(test_id): + _current_test_id = test_id + + +func end_test(): + _current_test_id = GutUtils.NO_TEST + + +func did_test_error(test_id=_current_test_id): + return errors.size(test_id) > 0 + + +func get_current_test_errors(): + return errors.items.get(_current_test_id, []) + + +# This should look through all the errors for a test and see if a failure +# should happen based off of flags. +func should_test_fail_from_errors(test_id = _current_test_id): + var to_return = false + if(errors.items.has(test_id)): + var errs = errors.items[test_id] + var index = 0 + while(index < errs.size() and !to_return): + var error = errs[index] + to_return = _is_error_failable(error) + index += 1 + return to_return + + +func get_errors_for_test(test_id=_current_test_id): + var to_return = [] + if(errors.items.has(test_id)): + to_return = errors.items[test_id].duplicate() + + return to_return + + +# Returns emtpy string or text for errors that occurred during the test that +# should cause failure based on this class' flags. +func get_fail_text_for_errors(test_id=_current_test_id) -> String: + var error_texts = [] + + if(errors.items.has(test_id)): + for error in errors.items[test_id]: + if(_is_error_failable(error)): + error_texts.append(str('<', error.get_error_type_name(), '>', error.code)) + + var to_return = "" + for i in error_texts.size(): + if(to_return != ""): + to_return += "\n" + to_return += str("[", i + 1, "] ", error_texts[i]) + + return to_return + + +func add_gut_error(text) -> GutTrackedError: + if(_current_test_id != GutUtils.NO_TEST): + var data = _get_stack_data(_current_test_id) + if(data.test_entry != {}): + return add_error(_current_test_id, data.test_entry.source, data.test_entry.line, + text, '', false, + GutUtils.GUT_ERROR_TYPE, data.full_stack) + + return add_error(_current_test_id, "unknown", -1, + text, '', false, + GutUtils.GUT_ERROR_TYPE, get_stack()) + + +func add_error(function: String, file: String, line: int, + code: String, rationale: String, editor_notify: bool, + error_type: int, script_backtraces: Array) -> GutTrackedError: + if(disabled): + return + + _mutex.lock() + + var err := GutTrackedError.new() + err.backtrace = script_backtraces + err.code = code + err.rationale = rationale + err.error_type = error_type + err.editor_notify = editor_notify + err.file = file + err.function = function + err.line = line + + errors.add(_current_test_id, err) + + _mutex.unlock() + + return err diff --git a/addons/gut/error_tracker.gd.uid b/addons/gut/error_tracker.gd.uid new file mode 100644 index 0000000..40ca392 --- /dev/null +++ b/addons/gut/error_tracker.gd.uid @@ -0,0 +1 @@ +uid://35kxgqotjmlu diff --git a/addons/gut/fonts/AnonymousPro-Bold.ttf b/addons/gut/fonts/AnonymousPro-Bold.ttf new file mode 100644 index 0000000..1d4bf2b Binary files /dev/null and b/addons/gut/fonts/AnonymousPro-Bold.ttf differ diff --git a/addons/gut/fonts/AnonymousPro-Bold.ttf.import b/addons/gut/fonts/AnonymousPro-Bold.ttf.import new file mode 100644 index 0000000..23867ed --- /dev/null +++ b/addons/gut/fonts/AnonymousPro-Bold.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://c8axnpxc0nrk4" +path="res://.godot/imported/AnonymousPro-Bold.ttf-9d8fef4d357af5b52cd60afbe608aa49.fontdata" + +[deps] + +source_file="res://addons/gut/fonts/AnonymousPro-Bold.ttf" +dest_files=["res://.godot/imported/AnonymousPro-Bold.ttf-9d8fef4d357af5b52cd60afbe608aa49.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/gut/fonts/AnonymousPro-BoldItalic.ttf b/addons/gut/fonts/AnonymousPro-BoldItalic.ttf new file mode 100644 index 0000000..12863ca Binary files /dev/null and b/addons/gut/fonts/AnonymousPro-BoldItalic.ttf differ diff --git a/addons/gut/fonts/AnonymousPro-BoldItalic.ttf.import b/addons/gut/fonts/AnonymousPro-BoldItalic.ttf.import new file mode 100644 index 0000000..0ec1e76 --- /dev/null +++ b/addons/gut/fonts/AnonymousPro-BoldItalic.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://msst1l2s2s" +path="res://.godot/imported/AnonymousPro-BoldItalic.ttf-4274bf704d3d6b9cd32c4f0754d8c83d.fontdata" + +[deps] + +source_file="res://addons/gut/fonts/AnonymousPro-BoldItalic.ttf" +dest_files=["res://.godot/imported/AnonymousPro-BoldItalic.ttf-4274bf704d3d6b9cd32c4f0754d8c83d.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/gut/fonts/AnonymousPro-Italic.ttf b/addons/gut/fonts/AnonymousPro-Italic.ttf new file mode 100644 index 0000000..f6870b7 Binary files /dev/null and b/addons/gut/fonts/AnonymousPro-Italic.ttf differ diff --git a/addons/gut/fonts/AnonymousPro-Italic.ttf.import b/addons/gut/fonts/AnonymousPro-Italic.ttf.import new file mode 100644 index 0000000..b5610ec --- /dev/null +++ b/addons/gut/fonts/AnonymousPro-Italic.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://hf5rdg67jcwc" +path="res://.godot/imported/AnonymousPro-Italic.ttf-9989590b02137b799e13d570de2a42c1.fontdata" + +[deps] + +source_file="res://addons/gut/fonts/AnonymousPro-Italic.ttf" +dest_files=["res://.godot/imported/AnonymousPro-Italic.ttf-9989590b02137b799e13d570de2a42c1.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/gut/fonts/AnonymousPro-Regular.ttf b/addons/gut/fonts/AnonymousPro-Regular.ttf new file mode 100644 index 0000000..57aa893 Binary files /dev/null and b/addons/gut/fonts/AnonymousPro-Regular.ttf differ diff --git a/addons/gut/fonts/AnonymousPro-Regular.ttf.import b/addons/gut/fonts/AnonymousPro-Regular.ttf.import new file mode 100644 index 0000000..82f2fc5 --- /dev/null +++ b/addons/gut/fonts/AnonymousPro-Regular.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://c6c7gnx36opr0" +path="res://.godot/imported/AnonymousPro-Regular.ttf-856c843fd6f89964d2ca8d8ff1724f13.fontdata" + +[deps] + +source_file="res://addons/gut/fonts/AnonymousPro-Regular.ttf" +dest_files=["res://.godot/imported/AnonymousPro-Regular.ttf-856c843fd6f89964d2ca8d8ff1724f13.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/gut/fonts/CourierPrime-Bold.ttf b/addons/gut/fonts/CourierPrime-Bold.ttf new file mode 100644 index 0000000..91d6de4 Binary files /dev/null and b/addons/gut/fonts/CourierPrime-Bold.ttf differ diff --git a/addons/gut/fonts/CourierPrime-Bold.ttf.import b/addons/gut/fonts/CourierPrime-Bold.ttf.import new file mode 100644 index 0000000..c498c6c --- /dev/null +++ b/addons/gut/fonts/CourierPrime-Bold.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://bhjgpy1dovmyq" +path="res://.godot/imported/CourierPrime-Bold.ttf-1f003c66d63ebed70964e7756f4fa235.fontdata" + +[deps] + +source_file="res://addons/gut/fonts/CourierPrime-Bold.ttf" +dest_files=["res://.godot/imported/CourierPrime-Bold.ttf-1f003c66d63ebed70964e7756f4fa235.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/gut/fonts/CourierPrime-BoldItalic.ttf b/addons/gut/fonts/CourierPrime-BoldItalic.ttf new file mode 100644 index 0000000..0afaa98 Binary files /dev/null and b/addons/gut/fonts/CourierPrime-BoldItalic.ttf differ diff --git a/addons/gut/fonts/CourierPrime-BoldItalic.ttf.import b/addons/gut/fonts/CourierPrime-BoldItalic.ttf.import new file mode 100644 index 0000000..30bf3fe --- /dev/null +++ b/addons/gut/fonts/CourierPrime-BoldItalic.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://n6mxiov5sbgc" +path="res://.godot/imported/CourierPrime-BoldItalic.ttf-65ebcc61dd5e1dfa8f96313da4ad7019.fontdata" + +[deps] + +source_file="res://addons/gut/fonts/CourierPrime-BoldItalic.ttf" +dest_files=["res://.godot/imported/CourierPrime-BoldItalic.ttf-65ebcc61dd5e1dfa8f96313da4ad7019.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/gut/fonts/CourierPrime-Italic.ttf b/addons/gut/fonts/CourierPrime-Italic.ttf new file mode 100644 index 0000000..f8a20bd Binary files /dev/null and b/addons/gut/fonts/CourierPrime-Italic.ttf differ diff --git a/addons/gut/fonts/CourierPrime-Italic.ttf.import b/addons/gut/fonts/CourierPrime-Italic.ttf.import new file mode 100644 index 0000000..ca30854 --- /dev/null +++ b/addons/gut/fonts/CourierPrime-Italic.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://mcht266g817e" +path="res://.godot/imported/CourierPrime-Italic.ttf-baa9156a73770735a0f72fb20b907112.fontdata" + +[deps] + +source_file="res://addons/gut/fonts/CourierPrime-Italic.ttf" +dest_files=["res://.godot/imported/CourierPrime-Italic.ttf-baa9156a73770735a0f72fb20b907112.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/gut/fonts/CourierPrime-Regular.ttf b/addons/gut/fonts/CourierPrime-Regular.ttf new file mode 100644 index 0000000..4f638f6 Binary files /dev/null and b/addons/gut/fonts/CourierPrime-Regular.ttf differ diff --git a/addons/gut/fonts/CourierPrime-Regular.ttf.import b/addons/gut/fonts/CourierPrime-Regular.ttf.import new file mode 100644 index 0000000..fc5d760 --- /dev/null +++ b/addons/gut/fonts/CourierPrime-Regular.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://bnh0lslf4yh87" +path="res://.godot/imported/CourierPrime-Regular.ttf-3babe7e4a7a588dfc9a84c14b4f1fe23.fontdata" + +[deps] + +source_file="res://addons/gut/fonts/CourierPrime-Regular.ttf" +dest_files=["res://.godot/imported/CourierPrime-Regular.ttf-3babe7e4a7a588dfc9a84c14b4f1fe23.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/gut/fonts/LobsterTwo-Bold.ttf b/addons/gut/fonts/LobsterTwo-Bold.ttf new file mode 100644 index 0000000..2e979fb Binary files /dev/null and b/addons/gut/fonts/LobsterTwo-Bold.ttf differ diff --git a/addons/gut/fonts/LobsterTwo-Bold.ttf.import b/addons/gut/fonts/LobsterTwo-Bold.ttf.import new file mode 100644 index 0000000..f03d313 --- /dev/null +++ b/addons/gut/fonts/LobsterTwo-Bold.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://cmiuntu71oyl3" +path="res://.godot/imported/LobsterTwo-Bold.ttf-7c7f734103b58a32491a4788186f3dcb.fontdata" + +[deps] + +source_file="res://addons/gut/fonts/LobsterTwo-Bold.ttf" +dest_files=["res://.godot/imported/LobsterTwo-Bold.ttf-7c7f734103b58a32491a4788186f3dcb.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/gut/fonts/LobsterTwo-BoldItalic.ttf b/addons/gut/fonts/LobsterTwo-BoldItalic.ttf new file mode 100644 index 0000000..8bbf8d8 Binary files /dev/null and b/addons/gut/fonts/LobsterTwo-BoldItalic.ttf differ diff --git a/addons/gut/fonts/LobsterTwo-BoldItalic.ttf.import b/addons/gut/fonts/LobsterTwo-BoldItalic.ttf.import new file mode 100644 index 0000000..36fb353 --- /dev/null +++ b/addons/gut/fonts/LobsterTwo-BoldItalic.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://bll38n2ct6qme" +path="res://.godot/imported/LobsterTwo-BoldItalic.ttf-227406a33e84448e6aa974176016de19.fontdata" + +[deps] + +source_file="res://addons/gut/fonts/LobsterTwo-BoldItalic.ttf" +dest_files=["res://.godot/imported/LobsterTwo-BoldItalic.ttf-227406a33e84448e6aa974176016de19.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/gut/fonts/LobsterTwo-Italic.ttf b/addons/gut/fonts/LobsterTwo-Italic.ttf new file mode 100644 index 0000000..b88ec17 Binary files /dev/null and b/addons/gut/fonts/LobsterTwo-Italic.ttf differ diff --git a/addons/gut/fonts/LobsterTwo-Italic.ttf.import b/addons/gut/fonts/LobsterTwo-Italic.ttf.import new file mode 100644 index 0000000..faba19b --- /dev/null +++ b/addons/gut/fonts/LobsterTwo-Italic.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://dis65h8wxc3f2" +path="res://.godot/imported/LobsterTwo-Italic.ttf-f93abf6c25390c85ad5fb6c4ee75159e.fontdata" + +[deps] + +source_file="res://addons/gut/fonts/LobsterTwo-Italic.ttf" +dest_files=["res://.godot/imported/LobsterTwo-Italic.ttf-f93abf6c25390c85ad5fb6c4ee75159e.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/gut/fonts/LobsterTwo-Regular.ttf b/addons/gut/fonts/LobsterTwo-Regular.ttf new file mode 100644 index 0000000..556c45e Binary files /dev/null and b/addons/gut/fonts/LobsterTwo-Regular.ttf differ diff --git a/addons/gut/fonts/LobsterTwo-Regular.ttf.import b/addons/gut/fonts/LobsterTwo-Regular.ttf.import new file mode 100644 index 0000000..56cdadb --- /dev/null +++ b/addons/gut/fonts/LobsterTwo-Regular.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://5e8msj0ih2pv" +path="res://.godot/imported/LobsterTwo-Regular.ttf-f3fcfa01cd671c8da433dd875d0fe04b.fontdata" + +[deps] + +source_file="res://addons/gut/fonts/LobsterTwo-Regular.ttf" +dest_files=["res://.godot/imported/LobsterTwo-Regular.ttf-f3fcfa01cd671c8da433dd875d0fe04b.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=1 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/addons/gut/fonts/OFL.txt b/addons/gut/fonts/OFL.txt new file mode 100644 index 0000000..3ed0152 --- /dev/null +++ b/addons/gut/fonts/OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2009, Mark Simonson (http://www.ms-studio.com, mark@marksimonson.com), +with Reserved Font Name Anonymous Pro. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/addons/gut/get_editor_interface.gd b/addons/gut/get_editor_interface.gd new file mode 100644 index 0000000..6ad312a --- /dev/null +++ b/addons/gut/get_editor_interface.gd @@ -0,0 +1,6 @@ +# This file is here so we can load it only when we are in the editor so that +# other places do not have to have "EditorInterface" in them, which causes a +# parser error when loaded outside of the editor. The things we have to do in +# order to test things is annoying. +func get_it(): + return EditorInterface \ No newline at end of file diff --git a/addons/gut/get_editor_interface.gd.uid b/addons/gut/get_editor_interface.gd.uid new file mode 100644 index 0000000..56aa1c1 --- /dev/null +++ b/addons/gut/get_editor_interface.gd.uid @@ -0,0 +1 @@ +uid://fgn2xo217kg1 diff --git a/addons/gut/gui/EditorRadioButton.tres b/addons/gut/gui/EditorRadioButton.tres new file mode 100644 index 0000000..ea2dae2 --- /dev/null +++ b/addons/gut/gui/EditorRadioButton.tres @@ -0,0 +1,13 @@ +[gd_resource type="Theme" load_steps=3 format=3 uid="uid://dssgvu257o1si"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_u716c"] +bg_color = Color(0.43137255, 0.8784314, 0.6156863, 0.5254902) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ht2pf"] +bg_color = Color(0, 0.44705883, 0.23921569, 1) + +[resource] +Button/colors/font_hover_pressed_color = Color(1, 1, 1, 1) +Button/colors/font_pressed_color = Color(1, 1, 1, 1) +Button/styles/hover = SubResource("StyleBoxFlat_u716c") +Button/styles/pressed = SubResource("StyleBoxFlat_ht2pf") diff --git a/addons/gut/gui/GutBottomPanel.gd b/addons/gut/gui/GutBottomPanel.gd new file mode 100644 index 0000000..315f3e1 --- /dev/null +++ b/addons/gut/gui/GutBottomPanel.gd @@ -0,0 +1,543 @@ +@tool +extends Control + +var GutEditorGlobals = load('res://addons/gut/gui/editor_globals.gd') +var GutConfigGui = load('res://addons/gut/gui/gut_config_gui.gd') +var AboutWindow = load("res://addons/gut/gui/about.tscn") + +var _interface = null; +var _is_running = false : + set(val): + _is_running = val + _disable_run_buttons(_is_running) + +var _gut_config = load('res://addons/gut/gut_config.gd').new() +var _gut_config_gui = null +var _gut_plugin = null +var _light_color = Color(0, 0, 0, .5) : + set(val): + _light_color = val + if(is_inside_tree()): + _ctrls.light.queue_redraw() +var _panel_button = null +var _user_prefs = null +var _shell_out_panel = null + + +var menu_manager = null : + set(val): + menu_manager = val + if(val != null): + _apply_shortcuts() + menu_manager.toggle_windowed.connect(_on_toggle_windowed) + menu_manager.about.connect(show_about) + menu_manager.run_all.connect(_run_all) + menu_manager.show_gut.connect(_on_show_gut) + + +@onready var _ctrls = { + about = %ExtraButtons/About, + light = %StatusIndicator, + output_button = %ExtraButtons/OutputBtn, + run_button = $layout/ControlBar/RunAll, + run_externally_dialog = $ShellOutOptions, + run_mode = %ExtraButtons/RunMode, + run_at_cursor = $layout/ControlBar/RunAtCursor, + run_results_button = %ExtraButtons/RunResultsBtn, + settings = $layout/RSplit/sc/Settings, + settings_button = %ExtraButtons/Settings, + shortcut_dialog = $ShortcutDialog, + shortcuts_button = %ExtraButtons/Shortcuts, + + results = { + bar = $layout/ControlBar2, + errors = %errors_value, + failing = %failing_value, + orphans = %orphans_value, + passing = %passing_value, + pending = %pending_value, + warnings = %warnings_value, + }, +} + +@onready var results_v_split = %VSplitResults +@onready var results_h_split = %HSplitResults +@onready var results_tree = %RunResults +@onready var results_text = %OutputText +@onready var make_floating_btn = %MakeFloating + + +func _ready(): + if(get_parent() is SubViewport): + return + + GutEditorGlobals.create_temp_directory() + + _user_prefs = GutEditorGlobals.user_prefs + _gut_config_gui = GutConfigGui.new(_ctrls.settings) + + _ctrls.results.bar.connect('draw', _on_results_bar_draw.bind(_ctrls.results.bar)) + hide_settings(!_ctrls.settings_button.button_pressed) + + _gut_config.load_options(GutEditorGlobals.editor_run_gut_config_path) + _gut_config_gui.set_options(_gut_config.options) + + _ctrls.shortcuts_button.icon = get_theme_icon('Shortcut', 'EditorIcons') + _ctrls.settings_button.icon = get_theme_icon('Tools', 'EditorIcons') + _ctrls.run_results_button.icon = get_theme_icon('AnimationTrackGroup', 'EditorIcons') # Tree + _ctrls.output_button.icon = get_theme_icon('Font', 'EditorIcons') + make_floating_btn.icon = get_theme_icon("MakeFloating", 'EditorIcons') + make_floating_btn.text = '' + _ctrls.about.icon = get_theme_icon('Info', 'EditorIcons') + _ctrls.about.text = '' + _ctrls.run_mode.icon = get_theme_icon("ViewportSpeed", 'EditorIcons') + + results_tree.set_output_control(results_text) + + var check_import = load('res://addons/gut/images/HSplitContainer.svg') + if(check_import == null): + results_tree.add_centered_text("GUT got some new images that are not imported yet. Please restart Godot.") + print('GUT got some new images that are not imported yet. Please restart Godot.') + else: + results_tree.add_centered_text("Let's run some tests!") + + _ctrls.run_externally_dialog.load_from_file() + _apply_options_to_controls() + + results_vert_layout() + + +func _process(_delta): + if(_is_running): + if(_ctrls.run_externally_dialog.should_run_externally()): + if(!is_instance_valid(_shell_out_panel)): + _is_running = false + show_me() + elif(!_interface.is_playing_scene()): + _is_running = false + results_text.add_text("\ndone") + load_result_output() + show_me() + + +# --------------- +# Private +# --------------- +func _apply_options_to_controls(): + hide_settings(_user_prefs.hide_settings.value) + hide_result_tree(_user_prefs.hide_result_tree.value) + hide_output_text(_user_prefs.hide_output_text.value) + results_tree.set_show_orphans(!_gut_config.options.hide_orphans) + var shell_dialog_size = _user_prefs.run_externally_options_dialog_size.value + + if(shell_dialog_size != Vector2i(-1, -1)): + _ctrls.run_externally_dialog.size = Vector2i(shell_dialog_size) + + if(_user_prefs.shortcuts_dialog_size.value != Vector2i(-1, -1)): + _ctrls.shortcut_dialog.size = _user_prefs.shortcuts_dialog_size.value + + var mode_ind = 'Ed' + if(_ctrls.run_externally_dialog.run_mode == _ctrls.run_externally_dialog.RUN_MODE_BLOCKING): + mode_ind = 'ExB' + elif(_ctrls.run_externally_dialog.run_mode == _ctrls.run_externally_dialog.RUN_MODE_NON_BLOCKING): + mode_ind = 'ExN' + _ctrls.run_mode.text = mode_ind + + _ctrls.run_at_cursor.apply_gut_config(_gut_config) + + + +func _disable_run_buttons(should): + _ctrls.run_button.disabled = should + _ctrls.run_at_cursor.disabled = should + + +func _is_test_script(script): + var from = script.get_base_script() + while(from and from.resource_path != 'res://addons/gut/test.gd'): + from = from.get_base_script() + + return from != null + + +func _show_errors(errs): + results_text.clear() + var text = "Cannot run tests, you have a configuration error:\n" + for e in errs: + text += str('* ', e, "\n") + text += "Check your settings ----->" + results_text.add_text(text) + hide_output_text(false) + hide_settings(false) + + +func _save_user_prefs(): + _user_prefs.hide_settings.value = !_ctrls.settings_button.button_pressed + _user_prefs.hide_result_tree.value = !_ctrls.run_results_button.button_pressed + _user_prefs.hide_output_text.value = !_ctrls.output_button.button_pressed + _user_prefs.shortcuts_dialog_size.value = _ctrls.shortcut_dialog.size + + _user_prefs.run_externally.value = _ctrls.run_externally_dialog.run_mode != _ctrls.run_externally_dialog.RUN_MODE_EDITOR + _user_prefs.run_externally_options_dialog_size.value = _ctrls.run_externally_dialog.size + + _user_prefs.save_it() + + +func _save_config(): + _save_user_prefs() + + _gut_config.options = _gut_config_gui.get_options(_gut_config.options) + var w_result = _gut_config.write_options(GutEditorGlobals.editor_run_gut_config_path) + if(w_result != OK): + push_error(str('Could not write options to ', GutEditorGlobals.editor_run_gut_config_path, ': ', w_result)) + else: + _gut_config_gui.mark_saved() + + +func _run_externally(): + _shell_out_panel = GutUtils.RunExternallyScene.instantiate() + _shell_out_panel.bottom_panel = self + _shell_out_panel.blocking_mode = _ctrls.run_externally_dialog.run_mode + _shell_out_panel.additional_arguments = _ctrls.run_externally_dialog.get_additional_arguments_array() + + add_child(_shell_out_panel) + _shell_out_panel.run_tests() + + +func _run_tests(): + show_me() + if(_is_running): + push_error("GUT: Cannot run tests, tests are already running.") + return + + clear_results() + GutEditorGlobals.create_temp_directory() + _light_color = Color.BLUE + + var issues = _gut_config_gui.get_config_issues() + if(issues.size() > 0): + _show_errors(issues) + return + + write_file(GutEditorGlobals.editor_run_bbcode_results_path, 'Run in progress') + write_file(GutEditorGlobals.editor_run_json_results_path, '') + _save_config() + _apply_options_to_controls() + + results_text.clear() + results_tree.clear() + results_tree.add_centered_text('Running...') + + _is_running = true + results_text.add_text('Running...') + + if(_ctrls.run_externally_dialog.should_run_externally()): + _gut_plugin.make_bottom_panel_item_visible(self) + _run_externally() + else: + _interface.play_custom_scene('res://addons/gut/gui/run_from_editor.tscn') + + +func _apply_shortcuts(): + if(menu_manager != null): + menu_manager.apply_gut_shortcuts(_ctrls.shortcut_dialog) + + _ctrls.run_button.shortcut = \ + _ctrls.shortcut_dialog.scbtn_run_all.get_shortcut() + _ctrls.run_at_cursor.get_script_button().shortcut = \ + _ctrls.shortcut_dialog.scbtn_run_current_script.get_shortcut() + _ctrls.run_at_cursor.get_inner_button().shortcut = \ + _ctrls.shortcut_dialog.scbtn_run_current_inner.get_shortcut() + _ctrls.run_at_cursor.get_test_button().shortcut = \ + _ctrls.shortcut_dialog.scbtn_run_current_test.get_shortcut() + # Took this out because it seems to break using the shortcut when docked. + # Though it does allow the shortcut to work when windowed. Shortcuts + # are weird. + # make_floating_btn.shortcut = \ + # _ctrls.shortcut_dialog.scbtn_windowed.get_shortcut() + + + if(_panel_button != null): + _panel_button.shortcut = _ctrls.shortcut_dialog.scbtn_panel.get_shortcut() + + +func _run_all(): + _gut_config.options.selected = null + _gut_config.options.inner_class = null + _gut_config.options.unit_test_name = null + + _run_tests() + + +# --------------- +# Events +# --------------- +func _on_results_bar_draw(bar): + bar.draw_rect(Rect2(Vector2(0, 0), bar.size), Color(0, 0, 0, .2)) + + +func _on_Light_draw(): + var l = _ctrls.light + l.draw_circle(Vector2(l.size.x / 2, l.size.y / 2), l.size.x / 2, _light_color) + + +func _on_RunAll_pressed(): + _run_all() + + +func _on_Shortcuts_pressed(): + _ctrls.shortcut_dialog.popup_centered() + + +func _on_sortcut_dialog_confirmed() -> void: + _apply_shortcuts() + _ctrls.shortcut_dialog.save_shortcuts() + _save_user_prefs() + + +func _on_RunAtCursor_run_tests(what): + _gut_config.options.selected = what.script + _gut_config.options.inner_class = what.inner_class + _gut_config.options.unit_test_name = what.method + + _run_tests() + + +func _on_Settings_pressed(): + hide_settings(!_ctrls.settings_button.button_pressed) + _save_config() + + +func _on_OutputBtn_pressed(): + hide_output_text(!_ctrls.output_button.button_pressed) + _save_config() + + +func _on_RunResultsBtn_pressed(): + hide_result_tree(! _ctrls.run_results_button.button_pressed) + _save_config() + + +# Currently not used, but will be when I figure out how to put +# colors into the text results +func _on_UseColors_pressed(): + pass + + +func _on_shell_out_options_confirmed() -> void: + _ctrls.run_externally_dialog.save_to_file() + _save_user_prefs() + _apply_options_to_controls() + + +func _on_run_mode_pressed() -> void: + _ctrls.run_externally_dialog.popup_centered() + + +func _on_toggle_windowed(): + _gut_plugin.toggle_windowed() + + +func _on_to_window_pressed() -> void: + _gut_plugin.toggle_windowed() + + +func _on_show_gut() -> void: + show_hide() + + +func _on_about_pressed() -> void: + show_about() + +# --------------- +# Public +# --------------- +func load_shortcuts(): + _ctrls.shortcut_dialog.load_shortcuts() + _apply_shortcuts() + + +func hide_result_tree(should): + results_tree.visible = !should + _ctrls.run_results_button.button_pressed = !should + + +func hide_settings(should): + var s_scroll = _ctrls.settings.get_parent() + s_scroll.visible = !should + + # collapse only collapses the first control, so we move + # settings around to be the collapsed one + if(should): + s_scroll.get_parent().move_child(s_scroll, 0) + else: + s_scroll.get_parent().move_child(s_scroll, 1) + + $layout/RSplit.collapsed = should + _ctrls.settings_button.button_pressed = !should + + +func hide_output_text(should): + results_text.visible = !should + _ctrls.output_button.button_pressed = !should + + +func clear_results(): + _light_color = Color(0, 0, 0, .5) + + _ctrls.results.passing.text = "0" + _ctrls.results.passing.get_parent().visible = false + + _ctrls.results.failing.text = "0" + _ctrls.results.failing.get_parent().visible = false + + _ctrls.results.pending.text = "0" + _ctrls.results.pending.get_parent().visible = false + + _ctrls.results.errors.text = "0" + _ctrls.results.errors.get_parent().visible = false + + _ctrls.results.warnings.text = "0" + _ctrls.results.warnings.get_parent().visible = false + + _ctrls.results.orphans.text = "0" + _ctrls.results.orphans.get_parent().visible = false + + +func load_result_json(): + var summary = get_file_as_text(GutEditorGlobals.editor_run_json_results_path) + var test_json_conv = JSON.new() + if (test_json_conv.parse(summary) != OK): + return + var results = test_json_conv.get_data() + + results_tree.load_json_results(results) + + var summary_json = results['test_scripts']['props'] + _ctrls.results.passing.text = str(int(summary_json.passing)) + _ctrls.results.passing.get_parent().visible = true + + _ctrls.results.failing.text = str(int(summary_json.failures)) + _ctrls.results.failing.get_parent().visible = true + + _ctrls.results.pending.text = str(int(summary_json.pending) + int(summary_json.risky)) + _ctrls.results.pending.get_parent().visible = _ctrls.results.pending.text != '0' + + _ctrls.results.errors.text = str(int(summary_json.errors)) + _ctrls.results.errors.get_parent().visible = _ctrls.results.errors.text != '0' + + _ctrls.results.warnings.text = str(int(summary_json.warnings)) + _ctrls.results.warnings.get_parent().visible = _ctrls.results.warnings.text != '0' + + _ctrls.results.orphans.text = str(int(summary_json.orphans)) + _ctrls.results.orphans.get_parent().visible = _ctrls.results.orphans.text != '0' and !_gut_config.options.hide_orphans + + if(summary_json.tests == 0): + _light_color = Color(1, 0, 0, .75) + elif(summary_json.failures != 0): + _light_color = Color(1, 0, 0, .75) + elif(summary_json.pending != 0 or summary_json.risky != 0): + _light_color = Color(1, 1, 0, .75) + else: + _light_color = Color(0, 1, 0, .75) + + _ctrls.light.visible = true + + +func load_result_text(): + results_text.load_file(GutEditorGlobals.editor_run_bbcode_results_path) + + +func load_result_output(): + load_result_text() + load_result_json() + + +func set_interface(value): + _interface = value + results_tree.set_interface(_interface) + + +func set_plugin(value): + _gut_plugin = value + + +func set_panel_button(value): + _panel_button = value + + +func write_file(path, content): + var f = FileAccess.open(path, FileAccess.WRITE) + if(f != null): + f.store_string(content) + f = null; + + return FileAccess.get_open_error() + + +func get_file_as_text(path): + var to_return = '' + var f = FileAccess.open(path, FileAccess.READ) + if(f != null): + to_return = f.get_as_text() + f = null + return to_return + + +func get_text_output_control(): + return results_text + + +func add_output_text(text): + results_text.add_text(text) + + +func show_about(): + var about = AboutWindow.instantiate() + add_child(about) + about.popup_centered() + about.confirmed.connect(about.queue_free) + + +func show_me(): + if(owner is Window): + owner.grab_focus() + else: + _gut_plugin.make_bottom_panel_item_visible(self) + + +func show_hide(): + if(owner is Window): + if(owner.has_focus()): + var win_to_focus_on = EditorInterface.get_editor_main_screen().get_parent() + while(win_to_focus_on != null and win_to_focus_on is not Window): + win_to_focus_on = win_to_focus_on.get_parent() + if(win_to_focus_on != null): + win_to_focus_on.grab_focus() + else: + owner.grab_focus() + else: + pass + # We don't have to do anything when we are docked because the GUT + # bottom panel has the shortcut and it does the toggling all on its + # own. + + +func get_shortcut_dialog(): + return _ctrls.shortcut_dialog + + +func results_vert_layout(): + if(results_tree.get_parent() != results_v_split): + results_tree.reparent(results_v_split) + results_text.reparent(results_v_split) + results_v_split.visible = true + results_h_split.visible = false + + +func results_horiz_layout(): + if(results_tree.get_parent() != results_h_split): + results_tree.reparent(results_h_split) + results_text.reparent(results_h_split) + results_v_split.visible = false + results_h_split.visible = true diff --git a/addons/gut/gui/GutBottomPanel.gd.uid b/addons/gut/gui/GutBottomPanel.gd.uid new file mode 100644 index 0000000..0de51df --- /dev/null +++ b/addons/gut/gui/GutBottomPanel.gd.uid @@ -0,0 +1 @@ +uid://dtvnb0xatk0my diff --git a/addons/gut/gui/GutBottomPanel.tscn b/addons/gut/gui/GutBottomPanel.tscn new file mode 100644 index 0000000..8257490 --- /dev/null +++ b/addons/gut/gui/GutBottomPanel.tscn @@ -0,0 +1,295 @@ +[gd_scene load_steps=10 format=3 uid="uid://b3bostcslstem"] + +[ext_resource type="Script" uid="uid://dtvnb0xatk0my" path="res://addons/gut/gui/GutBottomPanel.gd" id="1"] +[ext_resource type="PackedScene" uid="uid://0yunjxtaa8iw" path="res://addons/gut/gui/RunAtCursor.tscn" id="3"] +[ext_resource type="Texture2D" uid="uid://cr6tvdv0ve6cv" path="res://addons/gut/gui/play.png" id="4"] +[ext_resource type="Texture2D" uid="uid://bvo0uao7deu0q" path="res://addons/gut/icon.png" id="4_xv2r3"] +[ext_resource type="PackedScene" uid="uid://4gyyn12um08h" path="res://addons/gut/gui/RunResults.tscn" id="5"] +[ext_resource type="PackedScene" uid="uid://bqmo4dj64c7yl" path="res://addons/gut/gui/OutputText.tscn" id="6"] +[ext_resource type="PackedScene" uid="uid://dj5ve0bq7xa5j" path="res://addons/gut/gui/ShortcutDialog.tscn" id="7_srqj5"] +[ext_resource type="PackedScene" uid="uid://ckv5eh8xyrwbk" path="res://addons/gut/gui/ShellOutOptions.tscn" id="7_xv2r3"] + +[sub_resource type="Shortcut" id="9"] + +[node name="GutBottomPanel" type="Control"] +custom_minimum_size = Vector2(250, 250) +layout_mode = 3 +anchor_left = -0.0025866 +anchor_top = -0.00176575 +anchor_right = 0.997413 +anchor_bottom = 0.998234 +offset_left = 2.64868 +offset_top = 1.05945 +offset_right = 2.64862 +offset_bottom = 1.05945 +script = ExtResource("1") + +[node name="layout" type="VBoxContainer" parent="."] +layout_mode = 0 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="ControlBar" type="HBoxContainer" parent="layout"] +layout_mode = 2 + +[node name="RunAll" type="Button" parent="layout/ControlBar"] +layout_mode = 2 +size_flags_vertical = 11 +shortcut = SubResource("9") +text = "Run All" +icon = ExtResource("4") + +[node name="Sep3" type="ColorRect" parent="layout/ControlBar"] +custom_minimum_size = Vector2(1, 2.08165e-12) +layout_mode = 2 + +[node name="RunAtCursor" parent="layout/ControlBar" instance=ExtResource("3")] +custom_minimum_size = Vector2(648, 0) +layout_mode = 2 + +[node name="CenterContainer2" type="CenterContainer" parent="layout/ControlBar"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="MakeFloating" type="Button" parent="layout/ControlBar"] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "Move the GUT panel to a window." +icon = ExtResource("4_xv2r3") +flat = true + +[node name="ControlBar2" type="HBoxContainer" parent="layout"] +layout_mode = 2 + +[node name="Sep2" type="ColorRect" parent="layout/ControlBar2"] +custom_minimum_size = Vector2(1, 2.08165e-12) +layout_mode = 2 +color = Color(1, 1, 1, 0) + +[node name="StatusIndicator" type="Control" parent="layout/ControlBar2"] +unique_name_in_owner = true +custom_minimum_size = Vector2(30, 30) +layout_mode = 2 + +[node name="Passing" type="HBoxContainer" parent="layout/ControlBar2"] +visible = false +layout_mode = 2 + +[node name="Sep" type="ColorRect" parent="layout/ControlBar2/Passing"] +custom_minimum_size = Vector2(1, 2.08165e-12) +layout_mode = 2 + +[node name="label" type="Label" parent="layout/ControlBar2/Passing"] +layout_mode = 2 +text = "Pass" + +[node name="passing_value" type="Label" parent="layout/ControlBar2/Passing"] +unique_name_in_owner = true +layout_mode = 2 +text = "---" + +[node name="Failing" type="HBoxContainer" parent="layout/ControlBar2"] +visible = false +layout_mode = 2 + +[node name="Sep" type="ColorRect" parent="layout/ControlBar2/Failing"] +custom_minimum_size = Vector2(1, 2.08165e-12) +layout_mode = 2 + +[node name="label" type="Label" parent="layout/ControlBar2/Failing"] +layout_mode = 2 +text = "Fail" + +[node name="failing_value" type="Label" parent="layout/ControlBar2/Failing"] +unique_name_in_owner = true +layout_mode = 2 +text = "---" + +[node name="Pending" type="HBoxContainer" parent="layout/ControlBar2"] +visible = false +layout_mode = 2 + +[node name="Sep" type="ColorRect" parent="layout/ControlBar2/Pending"] +custom_minimum_size = Vector2(1, 2.08165e-12) +layout_mode = 2 + +[node name="label" type="Label" parent="layout/ControlBar2/Pending"] +layout_mode = 2 +text = "Risky" + +[node name="pending_value" type="Label" parent="layout/ControlBar2/Pending"] +unique_name_in_owner = true +layout_mode = 2 +text = "---" + +[node name="Orphans" type="HBoxContainer" parent="layout/ControlBar2"] +visible = false +layout_mode = 2 + +[node name="Sep" type="ColorRect" parent="layout/ControlBar2/Orphans"] +custom_minimum_size = Vector2(1, 2.08165e-12) +layout_mode = 2 + +[node name="label" type="Label" parent="layout/ControlBar2/Orphans"] +layout_mode = 2 +text = "Orphans" + +[node name="orphans_value" type="Label" parent="layout/ControlBar2/Orphans"] +unique_name_in_owner = true +layout_mode = 2 +text = "---" + +[node name="Errors" type="HBoxContainer" parent="layout/ControlBar2"] +visible = false +layout_mode = 2 + +[node name="Sep" type="ColorRect" parent="layout/ControlBar2/Errors"] +custom_minimum_size = Vector2(1, 2.08165e-12) +layout_mode = 2 + +[node name="label" type="Label" parent="layout/ControlBar2/Errors"] +layout_mode = 2 +text = "Errors" + +[node name="errors_value" type="Label" parent="layout/ControlBar2/Errors"] +unique_name_in_owner = true +layout_mode = 2 +text = "---" + +[node name="Warnings" type="HBoxContainer" parent="layout/ControlBar2"] +visible = false +layout_mode = 2 + +[node name="Sep" type="ColorRect" parent="layout/ControlBar2/Warnings"] +custom_minimum_size = Vector2(1, 2.08165e-12) +layout_mode = 2 + +[node name="label" type="Label" parent="layout/ControlBar2/Warnings"] +layout_mode = 2 +text = "Warnings" + +[node name="warnings_value" type="Label" parent="layout/ControlBar2/Warnings"] +unique_name_in_owner = true +layout_mode = 2 +text = "---" + +[node name="CenterContainer" type="CenterContainer" parent="layout/ControlBar2"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ExtraButtons" type="HBoxContainer" parent="layout/ControlBar2"] +unique_name_in_owner = true +layout_mode = 2 + +[node name="Sep1" type="ColorRect" parent="layout/ControlBar2/ExtraButtons"] +custom_minimum_size = Vector2(1, 2.08165e-12) +layout_mode = 2 + +[node name="RunMode" type="Button" parent="layout/ControlBar2/ExtraButtons"] +layout_mode = 2 +tooltip_text = "Run Mode. Run tests through the editor or externally in a different process." +text = "ExN" +icon = ExtResource("4_xv2r3") + +[node name="Sep2" type="ColorRect" parent="layout/ControlBar2/ExtraButtons"] +custom_minimum_size = Vector2(1, 2.08165e-12) +layout_mode = 2 + +[node name="RunResultsBtn" type="Button" parent="layout/ControlBar2/ExtraButtons"] +layout_mode = 2 +tooltip_text = "Show/Hide Result Tree" +toggle_mode = true +button_pressed = true +icon = ExtResource("4_xv2r3") + +[node name="OutputBtn" type="Button" parent="layout/ControlBar2/ExtraButtons"] +layout_mode = 2 +tooltip_text = "Show/Hide Text Output" +toggle_mode = true +button_pressed = true +icon = ExtResource("4_xv2r3") + +[node name="Settings" type="Button" parent="layout/ControlBar2/ExtraButtons"] +layout_mode = 2 +tooltip_text = "GUT Settings" +toggle_mode = true +button_pressed = true +icon = ExtResource("4_xv2r3") + +[node name="Sep3" type="ColorRect" parent="layout/ControlBar2/ExtraButtons"] +custom_minimum_size = Vector2(1, 2.08165e-12) +layout_mode = 2 + +[node name="Shortcuts" type="Button" parent="layout/ControlBar2/ExtraButtons"] +layout_mode = 2 +size_flags_vertical = 11 +tooltip_text = "GUT Shortcuts" +icon = ExtResource("4_xv2r3") + +[node name="About" type="Button" parent="layout/ControlBar2/ExtraButtons"] +layout_mode = 2 +tooltip_text = "About" +text = "A" + +[node name="RSplit" type="HSplitContainer" parent="layout"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="CResults" type="VBoxContainer" parent="layout/RSplit"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="HSplitResults" type="HSplitContainer" parent="layout/RSplit/CResults"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="RunResults" parent="layout/RSplit/CResults/HSplitResults" instance=ExtResource("5")] +unique_name_in_owner = true +custom_minimum_size = Vector2(255, 255) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="OutputText" parent="layout/RSplit/CResults/HSplitResults" instance=ExtResource("6")] +unique_name_in_owner = true +layout_mode = 2 + +[node name="VSplitResults" type="VSplitContainer" parent="layout/RSplit/CResults"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +size_flags_vertical = 3 + +[node name="sc" type="ScrollContainer" parent="layout/RSplit"] +custom_minimum_size = Vector2(500, 2.08165e-12) +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Settings" type="VBoxContainer" parent="layout/RSplit/sc"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="ShortcutDialog" parent="." instance=ExtResource("7_srqj5")] +visible = false + +[node name="ShellOutOptions" parent="." instance=ExtResource("7_xv2r3")] +size = Vector2i(1300, 1336) +visible = false + +[connection signal="pressed" from="layout/ControlBar/RunAll" to="." method="_on_RunAll_pressed"] +[connection signal="run_tests" from="layout/ControlBar/RunAtCursor" to="." method="_on_RunAtCursor_run_tests"] +[connection signal="pressed" from="layout/ControlBar/MakeFloating" to="." method="_on_to_window_pressed"] +[connection signal="draw" from="layout/ControlBar2/StatusIndicator" to="." method="_on_Light_draw"] +[connection signal="pressed" from="layout/ControlBar2/ExtraButtons/RunMode" to="." method="_on_run_mode_pressed"] +[connection signal="pressed" from="layout/ControlBar2/ExtraButtons/RunResultsBtn" to="." method="_on_RunResultsBtn_pressed"] +[connection signal="pressed" from="layout/ControlBar2/ExtraButtons/OutputBtn" to="." method="_on_OutputBtn_pressed"] +[connection signal="pressed" from="layout/ControlBar2/ExtraButtons/Settings" to="." method="_on_Settings_pressed"] +[connection signal="pressed" from="layout/ControlBar2/ExtraButtons/Shortcuts" to="." method="_on_Shortcuts_pressed"] +[connection signal="pressed" from="layout/ControlBar2/ExtraButtons/About" to="." method="_on_about_pressed"] +[connection signal="confirmed" from="ShortcutDialog" to="." method="_on_sortcut_dialog_confirmed"] +[connection signal="confirmed" from="ShellOutOptions" to="." method="_on_shell_out_options_confirmed"] diff --git a/addons/gut/gui/GutControl.gd b/addons/gut/gui/GutControl.gd new file mode 100644 index 0000000..ba63822 --- /dev/null +++ b/addons/gut/gui/GutControl.gd @@ -0,0 +1,326 @@ +@tool +extends Control + +const RUNNER_JSON_PATH = 'res://.gut_editor_config.json' + +var GutConfig = load('res://addons/gut/gut_config.gd') +var GutRunnerScene = load('res://addons/gut/gui/GutRunner.tscn') +var GutConfigGui = load('res://addons/gut/gui/gut_config_gui.gd') + +var _config = GutConfig.new() +var _config_gui = null +var _gut_runner = null +var _tree_root : TreeItem = null + +var _script_icon = load('res://addons/gut/images/Script.svg') +var _folder_icon = load('res://addons/gut/images/Folder.svg') + +var _tree_scripts = {} +var _tree_directories = {} + +const TREE_SCRIPT = 'Script' +const TREE_DIR = 'Directory' + +@onready var _ctrls = { + run_tests_button = $VBox/Buttons/RunTests, + run_selected = $VBox/Buttons/RunSelected, + test_tree = $VBox/Tabs/Tests, + settings_vbox = $VBox/Tabs/SettingsScroll/Settings, + tabs = $VBox/Tabs, + bg = $Bg +} + +@export var bg_color : Color = Color(.36, .36, .36) : + get: return bg_color + set(val): + bg_color = val + if(is_inside_tree()): + $Bg.color = bg_color + + +func _ready(): + if Engine.is_editor_hint(): + return + + _gut_runner = GutRunnerScene.instantiate() + $Bg.color = bg_color + _ctrls.tabs.set_tab_title(0, 'Tests') + _ctrls.tabs.set_tab_title(1, 'Settings') + + _config_gui = GutConfigGui.new(_ctrls.settings_vbox) + + _ctrls.test_tree.hide_root = true + add_child(_gut_runner) + + # TODO This might not need to be called deferred after changing GutUtils to + # an all static class. + call_deferred('_post_ready') + + +func _draw(): + if Engine.is_editor_hint(): + return + + var gut = _gut_runner.get_gut() + if(!gut.is_running()): + var r = Rect2(Vector2(0, 0), get_rect().size) + draw_rect(r, Color.BLACK, false, 2) + + +func _post_ready(): + var gut = _gut_runner.get_gut() + gut.start_run.connect(_on_gut_run_started) + gut.end_run.connect(_on_gut_run_ended) + _refresh_tree_and_settings() + + +func _set_meta_for_script_tree_item(item, script, test=null): + var meta = { + type = TREE_SCRIPT, + script = script.path, + inner_class = script.inner_class_name, + test = '' + } + + if(test != null): + meta.test = test.name + + item.set_metadata(0, meta) + + +func _set_meta_for_directory_tree_item(item, path, temp_item): + var meta = { + type = TREE_DIR, + path = path, + temp_item = temp_item + } + item.set_metadata(0, meta) + + +func _get_script_tree_item(script, parent_item): + if(!_tree_scripts.has(script.path)): + var item = _ctrls.test_tree.create_item(parent_item) + item.set_text(0, script.path.get_file()) + item.set_icon(0, _script_icon) + _tree_scripts[script.path] = item + _set_meta_for_script_tree_item(item, script) + + return _tree_scripts[script.path] + + +func _get_directory_tree_item(path): + var parent = _tree_root + if(!_tree_directories.has(path)): + + var item : TreeItem = null + if(parent != _tree_root): + item = parent.create_child(0) + else: + item = parent.create_child() + + _tree_directories[path] = item + item.collapsed = false + item.set_text(0, path) + item.set_icon(0, _folder_icon) + item.set_icon_modulate(0, Color.ROYAL_BLUE) + # temp_item is used in calls with move_before since you must use + # move_before or move_after to reparent tree items. This ensures that + # there is an item on all directories. These are deleted later. + var temp_item = item.create_child() + temp_item.set_text(0, '') + + _set_meta_for_directory_tree_item(item, path, temp_item) + + return _tree_directories[path] + + +func _find_dir_item_to_move_before(path): + var max_matching_len = 0 + var best_parent = null + + # Go through all the directory items finding the one that has the longest + # path that contains our path. + for key in _tree_directories.keys(): + if(path != key and path.begins_with(key) and key.length() > max_matching_len): + max_matching_len = key.length() + best_parent = _tree_directories[key] + + var to_return = null + if(best_parent != null): + to_return = best_parent.get_metadata(0).temp_item + return to_return + + +func _reorder_dir_items(): + var the_keys = _tree_directories.keys() + the_keys.sort() + for key in _tree_directories.keys(): + var to_move = _tree_directories[key] + to_move.collapsed = false + var move_before = _find_dir_item_to_move_before(key) + if(move_before != null): + to_move.move_before(move_before) + var new_text = key.substr(move_before.get_parent().get_metadata(0).path.length()) + to_move.set_text(0, new_text) + + +func _remove_dir_temp_items(): + for key in _tree_directories.keys(): + var item = _tree_directories[key].get_metadata(0).temp_item + _tree_directories[key].remove_child(item) + + +func _add_dir_and_script_tree_items(): + var tree : Tree = _ctrls.test_tree + tree.clear() + _tree_root = _ctrls.test_tree.create_item() + + var scripts = _gut_runner.get_gut().get_test_collector().scripts + for script in scripts: + var dir_item = _get_directory_tree_item(script.path.get_base_dir()) + var item = _get_script_tree_item(script, dir_item) + + if(script.inner_class_name != ''): + var inner_item = tree.create_item(item) + inner_item.set_text(0, script.inner_class_name) + _set_meta_for_script_tree_item(inner_item, script) + item = inner_item + + for test in script.tests: + var test_item = tree.create_item(item) + test_item.set_text(0, test.name) + _set_meta_for_script_tree_item(test_item, script, test) + + +func _populate_tree(): + _add_dir_and_script_tree_items() + _tree_root.set_collapsed_recursive(true) + _tree_root.set_collapsed(false) + _reorder_dir_items() + _remove_dir_temp_items() + + +func _refresh_tree_and_settings(): + _config.apply_options(_gut_runner.get_gut()) + _gut_runner.set_gut_config(_config) + _populate_tree() + +# --------------------------- +# Events +# --------------------------- +func _on_gut_run_started(): + _ctrls.run_tests_button.disabled = true + _ctrls.run_selected.visible = false + _ctrls.tabs.visible = false + _ctrls.bg.visible = false + _ctrls.run_tests_button.text = 'Running' + queue_redraw() + + +func _on_gut_run_ended(): + _ctrls.run_tests_button.disabled = false + _ctrls.run_selected.visible = true + _ctrls.tabs.visible = true + _ctrls.bg.visible = true + _ctrls.run_tests_button.text = 'Run All' + queue_redraw() + + +func _on_run_tests_pressed(): + run_all() + + +func _on_run_selected_pressed(): + run_selected() + + +func _on_tests_item_activated(): + run_selected() + +# --------------------------- +# Public +# --------------------------- +func get_gut(): + return _gut_runner.get_gut() + + +func get_config(): + return _config + + +func run_all(): + _config.options.selected = '' + _config.options.inner_class_name = '' + _config.options.unit_test_name = '' + run_tests() + + +func run_tests(options = null): + if(options == null): + _config.options = _config_gui.get_options(_config.options) + else: + _config.options = options + + # We ar running from within the game, so we should not exit, ever. + _config.options.should_exit_on_success = false + _config.options.should_exit = false + + _gut_runner.get_gut().get_test_collector().clear() + _gut_runner.set_gut_config(_config) + _gut_runner.run_tests() + + +func run_selected(): + var sel_item = _ctrls.test_tree.get_selected() + if(sel_item == null): + return + + var options = _config_gui.get_options(_config.options) + var meta = sel_item.get_metadata(0) + if(meta.type == TREE_SCRIPT): + options.selected = meta.script.get_file() + options.inner_class_name = meta.inner_class + options.unit_test_name = meta.test + elif(meta.type == TREE_DIR): + options.dirs = [meta.path] + options.include_subdirectories = true + options.selected = '' + options.inner_class_name = '' + options.unit_test_name = '' + + run_tests(options) + + +func load_config_file(path): + _config.load_options(path) + _config.options.selected = '' + _config.options.inner_class_name = '' + _config.options.unit_test_name = '' + _config_gui.load_file(path) + + +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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/gut/gui/GutControl.gd.uid b/addons/gut/gui/GutControl.gd.uid new file mode 100644 index 0000000..5776194 --- /dev/null +++ b/addons/gut/gui/GutControl.gd.uid @@ -0,0 +1 @@ +uid://cqlvpwidawld6 diff --git a/addons/gut/gui/GutControl.tscn b/addons/gut/gui/GutControl.tscn new file mode 100644 index 0000000..58301a2 --- /dev/null +++ b/addons/gut/gui/GutControl.tscn @@ -0,0 +1,63 @@ +[gd_scene load_steps=2 format=3 uid="uid://4jb53yqktyfg"] + +[ext_resource type="Script" uid="uid://cqlvpwidawld6" path="res://addons/gut/gui/GutControl.gd" id="1_eprql"] + +[node name="GutControl" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 295.0 +offset_bottom = 419.0 +script = ExtResource("1_eprql") + +[node name="Bg" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +color = Color(0.36, 0.36, 0.36, 1) + +[node name="VBox" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Tabs" type="TabContainer" parent="VBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Tests" type="Tree" parent="VBox/Tabs"] +layout_mode = 2 +size_flags_vertical = 3 +hide_root = true + +[node name="SettingsScroll" type="ScrollContainer" parent="VBox/Tabs"] +visible = false +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Settings" type="VBoxContainer" parent="VBox/Tabs/SettingsScroll"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Buttons" type="HBoxContainer" parent="VBox"] +layout_mode = 2 + +[node name="RunTests" type="Button" parent="VBox/Buttons"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Run All" + +[node name="RunSelected" type="Button" parent="VBox/Buttons"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Run Selected" + +[connection signal="item_activated" from="VBox/Tabs/Tests" to="." method="_on_tests_item_activated"] +[connection signal="pressed" from="VBox/Buttons/RunTests" to="." method="_on_run_tests_pressed"] +[connection signal="pressed" from="VBox/Buttons/RunSelected" to="." method="_on_run_selected_pressed"] diff --git a/addons/gut/gui/GutEditorWindow.gd b/addons/gut/gui/GutEditorWindow.gd new file mode 100644 index 0000000..3efcb84 --- /dev/null +++ b/addons/gut/gui/GutEditorWindow.gd @@ -0,0 +1,88 @@ +@tool +extends Window + + +var GutEditorGlobals = load('res://addons/gut/gui/editor_globals.gd') + +@onready var _chk_always_on_top = $Layout/WinControls/OnTop + +var _bottom_panel = null +var _ready_to_go = false +var _gut_shortcuts = [] + +var gut_plugin = null +var interface = null + + +func _unhandled_key_input(event: InputEvent) -> void: + if(event is InputEventKey): + if(_gut_shortcuts.has(event.as_text_keycode())): + get_tree().root.push_input(event) + + +func _ready() -> void: + var pref_size = GutEditorGlobals.user_prefs.gut_window_size.value + if(pref_size.x < 0): + size = Vector2(800, 800) + else: + size = pref_size + always_on_top = GutEditorGlobals.user_prefs.gut_window_on_top.value + _chk_always_on_top.button_pressed = always_on_top + + +# -------- +# Events +# -------- +func _on_on_top_toggled(toggled_on: bool) -> void: + always_on_top = toggled_on + GutEditorGlobals.user_prefs.gut_window_on_top.value = toggled_on + + +func _on_size_changed() -> void: + if(_ready_to_go): + GutEditorGlobals.user_prefs.gut_window_size.value = size + + +func _on_close_requested() -> void: + gut_plugin.toggle_windowed() + + + +func _on_vert_layout_pressed() -> void: + _bottom_panel.results_vert_layout() + + +func _on_horiz_layout_pressed() -> void: + _bottom_panel.results_horiz_layout() + + +# -------- +# Public +# -------- +func add_gut_panel(panel : Control): + $Layout.add_child(panel) + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + panel.visible = true + _bottom_panel = panel + _ready_to_go = true + + panel.owner = self + + # This stunk to figure out. + theme = interface.get_editor_theme() + var settings = interface.get_editor_settings() + $ColorRect.color = settings.get_setting("interface/theme/base_color") + + set_gut_shortcuts(_bottom_panel._ctrls.shortcut_dialog) + + +func remove_panel(): + $Layout.remove_child(_bottom_panel) + _bottom_panel.owner = null + + +func set_gut_shortcuts(shortcuts_dialog): + _gut_shortcuts.clear() + for btn in shortcuts_dialog.all_buttons: + _gut_shortcuts.append(btn.get_input_event().as_text_keycode()) diff --git a/addons/gut/gui/GutEditorWindow.gd.uid b/addons/gut/gui/GutEditorWindow.gd.uid new file mode 100644 index 0000000..2325973 --- /dev/null +++ b/addons/gut/gui/GutEditorWindow.gd.uid @@ -0,0 +1 @@ +uid://crp2af6k4nmf5 diff --git a/addons/gut/gui/GutEditorWindow.tscn b/addons/gut/gui/GutEditorWindow.tscn new file mode 100644 index 0000000..0da4ddb --- /dev/null +++ b/addons/gut/gui/GutEditorWindow.tscn @@ -0,0 +1,127 @@ +[gd_scene load_steps=10 format=3 uid="uid://dnnvwlplf1km7"] + +[ext_resource type="Script" uid="uid://crp2af6k4nmf5" path="res://addons/gut/gui/GutEditorWindow.gd" id="1_qevl3"] +[ext_resource type="Texture2D" uid="uid://ljc2viafngwd" path="res://addons/gut/images/HSplitContainer.svg" id="2_xw0o2"] +[ext_resource type="Texture2D" uid="uid://bhew20crsywxr" path="res://addons/gut/images/VSplitContainer.svg" id="3_fqfwy"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_qevl3"] +content_margin_left = 8.0 +content_margin_top = 8.0 +content_margin_right = 8.0 +content_margin_bottom = 8.0 +bg_color = Color(0.115499996, 0.132, 0.15949999, 1) +corner_detail = 1 +anti_aliasing = false + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_af010"] +content_margin_left = 8.0 +content_margin_top = 12.0 +content_margin_right = 8.0 +content_margin_bottom = 8.0 +bg_color = Color(0.21, 0.24, 0.29, 1) +border_color = Color(0.08399999, 0.095999986, 0.116, 1) +corner_radius_top_left = 6 +corner_radius_top_right = 6 +corner_radius_bottom_right = 6 +corner_radius_bottom_left = 6 +corner_detail = 5 +anti_aliasing = false + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xw0o2"] +content_margin_left = 0.0 +content_margin_top = 8.0 +content_margin_right = 0.0 +content_margin_bottom = 0.0 +bg_color = Color(0.21, 0.24, 0.29, 1) +border_color = Color(0.08399999, 0.095999986, 0.116, 1) +corner_radius_bottom_right = 6 +corner_radius_bottom_left = 6 +corner_detail = 5 +anti_aliasing = false + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_fqfwy"] +content_margin_left = 12.0 +content_margin_top = 8.0 +content_margin_right = 12.0 +content_margin_bottom = 8.0 +bg_color = Color(0.14699998, 0.16799998, 0.203, 1) +corner_radius_top_left = 6 +corner_radius_top_right = 6 +corner_radius_bottom_right = 6 +corner_radius_bottom_left = 6 +corner_detail = 5 +anti_aliasing = false + +[sub_resource type="Theme" id="Theme_fqfwy"] +Editor/colors/accent_color = Color(0.44, 0.73, 0.98, 1) +Editor/colors/background = Color(0.115499996, 0.132, 0.15949999, 1) +Editor/colors/base_color = Color(0.21, 0.24, 0.29, 1) +EditorStyles/styles/Background = SubResource("StyleBoxFlat_qevl3") +EditorStyles/styles/BottomPanel = SubResource("StyleBoxFlat_af010") +EditorStyles/styles/Content = SubResource("StyleBoxFlat_xw0o2") +Panel/styles/panel = SubResource("StyleBoxFlat_fqfwy") + +[sub_resource type="ButtonGroup" id="ButtonGroup_qevl3"] + +[node name="GutEditorWindow" type="Window"] +oversampling_override = 1.0 +title = "GUT" +position = Vector2i(0, 36) +size = Vector2i(1408, 1728) +min_size = Vector2i(800, 600) +script = ExtResource("1_qevl3") + +[node name="ColorRect" type="ColorRect" parent="."] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme = SubResource("Theme_fqfwy") +color = Color(0.18717614, 0.18717614, 0.18717614, 1) + +[node name="Layout" type="VBoxContainer" parent="."] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="WinControls" type="HBoxContainer" parent="Layout"] +layout_mode = 2 + +[node name="MenuBar" type="MenuBar" parent="Layout/WinControls"] +custom_minimum_size = Vector2(300, 0) +layout_mode = 2 +size_flags_horizontal = 3 +flat = true +prefer_global_menu = false + +[node name="CenterContainer" type="CenterContainer" parent="Layout/WinControls"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="OnTop" type="CheckButton" parent="Layout/WinControls"] +layout_mode = 2 +text = "Always on Top" + +[node name="HorizLayout" type="Button" parent="Layout/WinControls"] +texture_filter = 1 +layout_mode = 2 +toggle_mode = true +button_pressed = true +button_group = SubResource("ButtonGroup_qevl3") +icon = ExtResource("2_xw0o2") +icon_alignment = 1 + +[node name="VertLayout" type="Button" parent="Layout/WinControls"] +layout_mode = 2 +toggle_mode = true +button_group = SubResource("ButtonGroup_qevl3") +icon = ExtResource("3_fqfwy") + +[connection signal="close_requested" from="." to="." method="_on_close_requested"] +[connection signal="size_changed" from="." to="." method="_on_size_changed"] +[connection signal="toggled" from="Layout/WinControls/OnTop" to="." method="_on_on_top_toggled"] +[connection signal="pressed" from="Layout/WinControls/HorizLayout" to="." method="_on_horiz_layout_pressed"] +[connection signal="pressed" from="Layout/WinControls/VertLayout" to="." method="_on_vert_layout_pressed"] diff --git a/addons/gut/gui/GutLogo.tscn b/addons/gut/gui/GutLogo.tscn new file mode 100644 index 0000000..e73d259 --- /dev/null +++ b/addons/gut/gui/GutLogo.tscn @@ -0,0 +1,36 @@ +[gd_scene load_steps=4 format=3 uid="uid://bjkn8mhx2fmt1"] + +[ext_resource type="Script" uid="uid://b8lvgepb64m8t" path="res://addons/gut/gui/gut_logo.gd" id="1_ba6lh"] +[ext_resource type="Texture2D" uid="uid://dyxbmyvpkkcvs" path="res://addons/gut/images/GutIconV2_base.png" id="2_ba6lh"] +[ext_resource type="Texture2D" uid="uid://dx0yxxn5q7doc" path="res://addons/gut/images/eyey.png" id="3_rc8fb"] + +[node name="Logo" type="Node2D"] +script = ExtResource("1_ba6lh") + +[node name="BaseLogo" type="Sprite2D" parent="."] +scale = Vector2(0.5, 0.5) +texture = ExtResource("2_ba6lh") + +[node name="LeftEye" type="Sprite2D" parent="BaseLogo"] +visible = false +position = Vector2(-238, 16) +texture = ExtResource("3_rc8fb") + +[node name="RightEye" type="Sprite2D" parent="BaseLogo"] +visible = false +position = Vector2(239, 16) +texture = ExtResource("3_rc8fb") + +[node name="ResetTimer" type="Timer" parent="."] +wait_time = 5.0 +one_shot = true + +[node name="FaceButton" type="Button" parent="."] +modulate = Color(1, 1, 1, 0) +offset_left = -141.0 +offset_top = -113.0 +offset_right = 140.0 +offset_bottom = 175.0 + +[connection signal="timeout" from="ResetTimer" to="." method="_on_reset_timer_timeout"] +[connection signal="pressed" from="FaceButton" to="." method="_on_face_button_pressed"] diff --git a/addons/gut/gui/GutRunner.gd b/addons/gut/gui/GutRunner.gd new file mode 100644 index 0000000..7b4bb57 --- /dev/null +++ b/addons/gut/gui/GutRunner.gd @@ -0,0 +1,243 @@ +# ############################################################################## +# This class joins together GUT, GUT Gui, GutConfig and is THE way to kick off a +# run of a test suite. +# +# This creates its own instance of gut.gd that it manages. You can set the +# gut.gd instance if you need to for testing. +# +# Set gut_config to an instance of a configured gut_config.gd instance prior to +# running tests. +# +# This will create a GUI and wire it up and apply gut_config.gd options. +# +# Running tests: Call run_tests +# ############################################################################## +extends Node2D + +const EXIT_OK = 0 +const EXIT_ERROR = 1 + +var Gut = load('res://addons/gut/gut.gd') +var ResultExporter = load('res://addons/gut/result_exporter.gd') +var GutConfig = load('res://addons/gut/gut_config.gd') + +var runner_json_path = null +var result_bbcode_path = null +var result_json_path = null + +var lgr = GutUtils.get_logger() +var gut_config = null + +var error_tracker = GutUtils.get_error_tracker() + +var _hid_gut = null; +# Lazy loaded gut instance. Settable for testing purposes. +var gut = _hid_gut : + get: + if(_hid_gut == null): + _hid_gut = Gut.new(lgr) + _hid_gut.error_tracker = error_tracker + return _hid_gut + set(val): + _hid_gut = val + +var _wrote_results = false +var _ran_from_editor = false + +@onready var _gut_layer = $GutLayer +@onready var _gui = $GutLayer/GutScene + + +func _ready(): + GutUtils.WarningsManager.apply_warnings_dictionary( + GutUtils.warnings_at_start) + + +func _exit_tree(): + if(!_wrote_results and _ran_from_editor): + _write_results_for_gut_panel() + + +func _setup_gui(show_gui): + if(show_gui): + _gui.gut = gut + var printer = gut.logger.get_printer('gui') + printer.set_textbox(_gui.get_textbox()) + else: + gut.logger.disable_printer('gui', true) + _gui.visible = false + + var opts = gut_config.options + _gui.set_font_size(opts.font_size) + _gui.set_font(opts.font_name) + if(opts.font_color != null and opts.font_color.is_valid_html_color()): + _gui.set_default_font_color(Color(opts.font_color)) + if(opts.background_color != null and opts.background_color.is_valid_html_color()): + _gui.set_background_color(Color(opts.background_color)) + + _gui.set_opacity(min(1.0, float(opts.opacity) / 100)) + _gui.use_compact_mode(opts.compact_mode) + + +func _write_results_for_gut_panel(): + var content = _gui.get_textbox().get_parsed_text() #_gut.logger.get_gui_bbcode() + var f = FileAccess.open(result_bbcode_path, FileAccess.WRITE) + if(f != null): + f.store_string(content) + f = null # closes file + else: + push_error('Could not save bbcode, result = ', FileAccess.get_open_error()) + + var exporter = ResultExporter.new() + # TODO this should be checked and _wrote_results should maybe not be set, or + # maybe we do not care. Whichever, it should be clear. + var _f_result = exporter.write_json_file(gut, result_json_path) + _wrote_results = true + + +func _handle_quit(should_exit, should_exit_on_success, override_exit_code=EXIT_OK): + var quitting_time = should_exit or \ + (should_exit_on_success and gut.get_fail_count() == 0) + + if(!quitting_time): + if(should_exit_on_success): + lgr.log("There are failing tests, exit manually.") + _gui.use_compact_mode(false) + return + + # For some reason, tests fail asserting that quit was called with 0 if we + # do not do this, but everything is defaulted so I don't know why it gets + # null. + var exit_code = GutUtils.nvl(override_exit_code, EXIT_OK) + + if(gut.get_fail_count() > 0): + exit_code = EXIT_ERROR + + # Overwrite the exit code with the post_script's exit code if it is set + var post_hook_inst = gut.get_post_run_script_instance() + if(post_hook_inst != null and post_hook_inst.get_exit_code() != null): + exit_code = post_hook_inst.get_exit_code() + + quit(exit_code) + + +func _end_run(override_exit_code=EXIT_OK): + if(_ran_from_editor): + _write_results_for_gut_panel() + + GutErrorTracker.deregister_logger(error_tracker) + + _handle_quit(gut_config.options.should_exit, + gut_config.options.should_exit_on_success, + override_exit_code) + + +# ------------- +# Events +# ------------- +func _on_tests_finished(): + _end_run() + + +# ------------- +# Public +# ------------- +# For internal use only, but still public. Consider it "protected" and you +# don't have my permission to call this, unless "you" is "me". +func run_from_editor(): + _ran_from_editor = true + var GutEditorGlobals = load('res://addons/gut/gui/editor_globals.gd') + runner_json_path = GutUtils.nvl(runner_json_path, GutEditorGlobals.editor_run_gut_config_path) + result_bbcode_path = GutUtils.nvl(result_bbcode_path, GutEditorGlobals.editor_run_bbcode_results_path) + result_json_path = GutUtils.nvl(result_json_path, GutEditorGlobals.editor_run_json_results_path) + + if(gut_config == null): + gut_config = GutConfig.new() + gut_config.load_options(runner_json_path) + + call_deferred('run_tests') + + +func run_tests(show_gui=true): + _setup_gui(show_gui) + + if(gut_config.options.dirs.size() + gut_config.options.tests.size() == 0): + var err_text = "You do not have any directories configured, so GUT " + \ + "doesn't know where to find the tests. Tell GUT where to find the " + \ + "tests and GUT shall run the tests." + lgr.error(err_text) + push_error(err_text) + _end_run(EXIT_ERROR) + return + + var install_check_text = GutUtils.make_install_check_text() + if(install_check_text != GutUtils.INSTALL_OK_TEXT): + print("\n\n", GutUtils.version_numbers.get_version_text()) + lgr.error(install_check_text) + push_error(install_check_text) + _end_run(EXIT_ERROR) + return + + gut.add_children_to = self + if(gut.get_parent() == null): + if(gut_config.options.gut_on_top): + _gut_layer.add_child(gut) + else: + add_child(gut) + + if(!gut.end_run.is_connected(_on_tests_finished)): + gut.end_run.connect(_on_tests_finished) + + gut_config.apply_options(gut) + var run_rest_of_scripts = gut_config.options.unit_test_name == '' + GutErrorTracker.register_logger(error_tracker) + gut.test_scripts(run_rest_of_scripts) + + +func set_gut_config(which): + gut_config = which + + +# for backwards compatibility +func get_gut(): + return gut + + +func quit(exit_code): + # Sometimes quitting takes a few seconds. This gives some indicator + # of what is going on. + _gui.set_title("Exiting") + + await get_tree().process_frame + + lgr.info(str('Exiting with code ', exit_code)) + get_tree().quit(exit_code) + + + + +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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/gut/gui/GutRunner.gd.uid b/addons/gut/gui/GutRunner.gd.uid new file mode 100644 index 0000000..b5971e8 --- /dev/null +++ b/addons/gut/gui/GutRunner.gd.uid @@ -0,0 +1 @@ +uid://eg8k46gd42a4 diff --git a/addons/gut/gui/GutRunner.tscn b/addons/gut/gui/GutRunner.tscn new file mode 100644 index 0000000..a4e966f --- /dev/null +++ b/addons/gut/gui/GutRunner.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=3 format=3 uid="uid://bqy3ikt6vu4b5"] + +[ext_resource type="Script" uid="uid://eg8k46gd42a4" path="res://addons/gut/gui/GutRunner.gd" id="1"] +[ext_resource type="PackedScene" uid="uid://m28heqtswbuq" path="res://addons/gut/GutScene.tscn" id="2_6ruxb"] + +[node name="GutRunner" type="Node2D"] +script = ExtResource("1") + +[node name="GutLayer" type="CanvasLayer" parent="."] +layer = 128 + +[node name="GutScene" parent="GutLayer" instance=ExtResource("2_6ruxb")] diff --git a/addons/gut/gui/GutSceneTheme.tres b/addons/gut/gui/GutSceneTheme.tres new file mode 100644 index 0000000..157320a --- /dev/null +++ b/addons/gut/gui/GutSceneTheme.tres @@ -0,0 +1,7 @@ +[gd_resource type="Theme" load_steps=2 format=3 uid="uid://cstkhwkpajvqu"] + +[ext_resource type="FontFile" uid="uid://c6c7gnx36opr0" path="res://addons/gut/fonts/AnonymousPro-Regular.ttf" id="1_df57p"] + +[resource] +default_font = ExtResource("1_df57p") +Label/fonts/font = ExtResource("1_df57p") diff --git a/addons/gut/gui/MinGui.tscn b/addons/gut/gui/MinGui.tscn new file mode 100644 index 0000000..f347e5c --- /dev/null +++ b/addons/gut/gui/MinGui.tscn @@ -0,0 +1,161 @@ +[gd_scene load_steps=5 format=3 uid="uid://cnqqdfsn80ise"] + +[ext_resource type="Theme" uid="uid://cstkhwkpajvqu" path="res://addons/gut/gui/GutSceneTheme.tres" id="1_farmq"] +[ext_resource type="FontFile" uid="uid://bnh0lslf4yh87" path="res://addons/gut/fonts/CourierPrime-Regular.ttf" id="2_a2e2l"] +[ext_resource type="Script" uid="uid://blvhsbnsvfyow" path="res://addons/gut/gui/gut_gui.gd" id="2_eokrf"] +[ext_resource type="PackedScene" uid="uid://bvrqqgjpyouse" path="res://addons/gut/gui/ResizeHandle.tscn" id="4_xrhva"] + +[node name="Min" type="Panel"] +clip_contents = true +custom_minimum_size = Vector2(280, 145) +offset_right = 280.0 +offset_bottom = 145.0 +theme = ExtResource("1_farmq") +script = ExtResource("2_eokrf") + +[node name="MainBox" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +metadata/_edit_layout_mode = 1 + +[node name="TitleBar" type="Panel" parent="MainBox"] +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 + +[node name="TitleBox" type="HBoxContainer" parent="MainBox/TitleBar"] +layout_mode = 0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = 2.0 +offset_bottom = 3.0 +grow_horizontal = 2 +grow_vertical = 2 +metadata/_edit_layout_mode = 1 + +[node name="Spacer1" type="CenterContainer" parent="MainBox/TitleBar/TitleBox"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Title" type="Label" parent="MainBox/TitleBar/TitleBox"] +layout_mode = 2 +text = "Title" + +[node name="Spacer2" type="CenterContainer" parent="MainBox/TitleBar/TitleBox"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="TimeLabel" type="Label" parent="MainBox/TitleBar/TitleBox"] +layout_mode = 2 +text = "0.000s" + +[node name="Body" type="HBoxContainer" parent="MainBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="LeftMargin" type="CenterContainer" parent="MainBox/Body"] +custom_minimum_size = Vector2(5, 0) +layout_mode = 2 + +[node name="BodyRows" type="VBoxContainer" parent="MainBox/Body"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ProgressBars" type="HBoxContainer" parent="MainBox/Body/BodyRows"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="HBoxContainer" type="HBoxContainer" parent="MainBox/Body/BodyRows/ProgressBars"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Label" type="Label" parent="MainBox/Body/BodyRows/ProgressBars/HBoxContainer"] +layout_mode = 2 +text = "T:" + +[node name="ProgressTest" type="ProgressBar" parent="MainBox/Body/BodyRows/ProgressBars/HBoxContainer"] +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +size_flags_horizontal = 3 +value = 25.0 + +[node name="HBoxContainer2" type="HBoxContainer" parent="MainBox/Body/BodyRows/ProgressBars"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Label" type="Label" parent="MainBox/Body/BodyRows/ProgressBars/HBoxContainer2"] +layout_mode = 2 +text = "S:" + +[node name="ProgressScript" type="ProgressBar" parent="MainBox/Body/BodyRows/ProgressBars/HBoxContainer2"] +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +size_flags_horizontal = 3 +value = 75.0 + +[node name="PathDisplay" type="VBoxContainer" parent="MainBox/Body/BodyRows"] +clip_contents = true +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Path" type="Label" parent="MainBox/Body/BodyRows/PathDisplay"] +layout_mode = 2 +theme_override_fonts/font = ExtResource("2_a2e2l") +theme_override_font_sizes/font_size = 14 +text = "res://test/integration/whatever" +clip_text = true +text_overrun_behavior = 3 + +[node name="HBoxContainer" type="HBoxContainer" parent="MainBox/Body/BodyRows/PathDisplay"] +clip_contents = true +layout_mode = 2 + +[node name="S3" type="CenterContainer" parent="MainBox/Body/BodyRows/PathDisplay/HBoxContainer"] +custom_minimum_size = Vector2(5, 0) +layout_mode = 2 + +[node name="File" type="Label" parent="MainBox/Body/BodyRows/PathDisplay/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_fonts/font = ExtResource("2_a2e2l") +theme_override_font_sizes/font_size = 14 +text = "test_this_thing.gd" +text_overrun_behavior = 3 + +[node name="Footer" type="HBoxContainer" parent="MainBox/Body/BodyRows"] +layout_mode = 2 + +[node name="HandleLeft" parent="MainBox/Body/BodyRows/Footer" node_paths=PackedStringArray("resize_control") instance=ExtResource("4_xrhva")] +layout_mode = 2 +orientation = 0 +resize_control = NodePath("../../../../..") +vertical_resize = false + +[node name="SwitchModes" type="Button" parent="MainBox/Body/BodyRows/Footer"] +layout_mode = 2 +text = "Expand" + +[node name="CenterContainer" type="CenterContainer" parent="MainBox/Body/BodyRows/Footer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Continue" type="Button" parent="MainBox/Body/BodyRows/Footer"] +layout_mode = 2 +text = "Continue +" + +[node name="HandleRight" parent="MainBox/Body/BodyRows/Footer" node_paths=PackedStringArray("resize_control") instance=ExtResource("4_xrhva")] +layout_mode = 2 +resize_control = NodePath("../../../../..") +vertical_resize = false + +[node name="RightMargin" type="CenterContainer" parent="MainBox/Body"] +custom_minimum_size = Vector2(5, 0) +layout_mode = 2 + +[node name="CenterContainer" type="CenterContainer" parent="MainBox"] +custom_minimum_size = Vector2(2.08165e-12, 2) +layout_mode = 2 diff --git a/addons/gut/gui/NormalGui.tscn b/addons/gut/gui/NormalGui.tscn new file mode 100644 index 0000000..e729d0d --- /dev/null +++ b/addons/gut/gui/NormalGui.tscn @@ -0,0 +1,215 @@ +[gd_scene load_steps=5 format=3 uid="uid://duxblir3vu8x7"] + +[ext_resource type="Theme" uid="uid://cstkhwkpajvqu" path="res://addons/gut/gui/GutSceneTheme.tres" id="1_5hlsm"] +[ext_resource type="Script" uid="uid://blvhsbnsvfyow" path="res://addons/gut/gui/gut_gui.gd" id="2_fue6q"] +[ext_resource type="FontFile" uid="uid://bnh0lslf4yh87" path="res://addons/gut/fonts/CourierPrime-Regular.ttf" id="2_u5uc1"] +[ext_resource type="PackedScene" uid="uid://bvrqqgjpyouse" path="res://addons/gut/gui/ResizeHandle.tscn" id="4_2r8a8"] + +[node name="Large" type="Panel"] +custom_minimum_size = Vector2(500, 150) +offset_right = 632.0 +offset_bottom = 260.0 +theme = ExtResource("1_5hlsm") +script = ExtResource("2_fue6q") + +[node name="MainBox" type="VBoxContainer" parent="."] +layout_mode = 0 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +metadata/_edit_layout_mode = 1 + +[node name="TitleBar" type="Panel" parent="MainBox"] +custom_minimum_size = Vector2(0, 25) +layout_mode = 2 + +[node name="TitleBox" type="HBoxContainer" parent="MainBox/TitleBar"] +layout_mode = 0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_top = 2.0 +offset_bottom = 3.0 +grow_horizontal = 2 +grow_vertical = 2 +metadata/_edit_layout_mode = 1 + +[node name="Spacer1" type="CenterContainer" parent="MainBox/TitleBar/TitleBox"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Title" type="Label" parent="MainBox/TitleBar/TitleBox"] +layout_mode = 2 +text = "Title" + +[node name="Spacer2" type="CenterContainer" parent="MainBox/TitleBar/TitleBox"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="TimeLabel" type="Label" parent="MainBox/TitleBar/TitleBox"] +custom_minimum_size = Vector2(90, 0) +layout_mode = 2 +text = "999.999s" + +[node name="HBoxContainer" type="HBoxContainer" parent="MainBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="VBoxContainer" type="VBoxContainer" parent="MainBox/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="OutputBG" type="ColorRect" parent="MainBox/HBoxContainer/VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 +color = Color(0.0745098, 0.0705882, 0.0784314, 1) +metadata/_edit_layout_mode = 1 + +[node name="HBoxContainer" type="HBoxContainer" parent="MainBox/HBoxContainer/VBoxContainer/OutputBG"] +layout_mode = 0 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="S2" type="CenterContainer" parent="MainBox/HBoxContainer/VBoxContainer/OutputBG/HBoxContainer"] +custom_minimum_size = Vector2(5, 0) +layout_mode = 2 + +[node name="TestOutput" type="RichTextLabel" parent="MainBox/HBoxContainer/VBoxContainer/OutputBG/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +focus_mode = 2 +bbcode_enabled = true +scroll_following = true +autowrap_mode = 0 +selection_enabled = true + +[node name="S1" type="CenterContainer" parent="MainBox/HBoxContainer/VBoxContainer/OutputBG/HBoxContainer"] +custom_minimum_size = Vector2(5, 0) +layout_mode = 2 + +[node name="ControlBox" type="HBoxContainer" parent="MainBox/HBoxContainer/VBoxContainer"] +layout_mode = 2 + +[node name="S1" type="CenterContainer" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox"] +custom_minimum_size = Vector2(5, 0) +layout_mode = 2 + +[node name="ProgressBars" type="VBoxContainer" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox"] +custom_minimum_size = Vector2(2.08165e-12, 2.08165e-12) +layout_mode = 2 + +[node name="TestBox" type="HBoxContainer" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox/ProgressBars"] +layout_mode = 2 + +[node name="Label" type="Label" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox/ProgressBars/TestBox"] +custom_minimum_size = Vector2(60, 0) +layout_mode = 2 +size_flags_horizontal = 3 +text = "Tests" + +[node name="ProgressTest" type="ProgressBar" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox/ProgressBars/TestBox"] +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +value = 25.0 + +[node name="ScriptBox" type="HBoxContainer" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox/ProgressBars"] +layout_mode = 2 + +[node name="Label" type="Label" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox/ProgressBars/ScriptBox"] +custom_minimum_size = Vector2(60, 0) +layout_mode = 2 +size_flags_horizontal = 3 +text = "Scripts" + +[node name="ProgressScript" type="ProgressBar" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox/ProgressBars/ScriptBox"] +custom_minimum_size = Vector2(100, 0) +layout_mode = 2 +value = 75.0 + +[node name="PathDisplay" type="VBoxContainer" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Path" type="Label" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox/PathDisplay"] +layout_mode = 2 +size_flags_vertical = 6 +theme_override_fonts/font = ExtResource("2_u5uc1") +theme_override_font_sizes/font_size = 14 +text = "res://test/integration/whatever" +text_overrun_behavior = 3 + +[node name="HBoxContainer" type="HBoxContainer" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox/PathDisplay"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="S3" type="CenterContainer" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox/PathDisplay/HBoxContainer"] +custom_minimum_size = Vector2(5, 0) +layout_mode = 2 + +[node name="File" type="Label" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox/PathDisplay/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_fonts/font = ExtResource("2_u5uc1") +theme_override_font_sizes/font_size = 14 +text = "test_this_thing.gd" +text_overrun_behavior = 3 + +[node name="Buttons" type="VBoxContainer" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox"] +layout_mode = 2 + +[node name="Continue" type="Button" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox/Buttons"] +layout_mode = 2 +size_flags_vertical = 4 +text = "Continue +" + +[node name="WordWrap" type="CheckButton" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox/Buttons"] +layout_mode = 2 +text = "Word Wrap" + +[node name="S3" type="CenterContainer" parent="MainBox/HBoxContainer/VBoxContainer/ControlBox"] +custom_minimum_size = Vector2(5, 0) +layout_mode = 2 + +[node name="BottomPad" type="CenterContainer" parent="MainBox"] +custom_minimum_size = Vector2(0, 5) +layout_mode = 2 + +[node name="Footer" type="HBoxContainer" parent="MainBox"] +layout_mode = 2 + +[node name="SidePad1" type="CenterContainer" parent="MainBox/Footer"] +custom_minimum_size = Vector2(2, 2.08165e-12) +layout_mode = 2 + +[node name="ResizeHandle3" parent="MainBox/Footer" node_paths=PackedStringArray("resize_control") instance=ExtResource("4_2r8a8")] +custom_minimum_size = Vector2(25, 25) +layout_mode = 2 +orientation = 0 +resize_control = NodePath("../../..") + +[node name="SwitchModes" type="Button" parent="MainBox/Footer"] +layout_mode = 2 +text = "Compact +" + +[node name="CenterContainer" type="CenterContainer" parent="MainBox/Footer"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ResizeHandle2" parent="MainBox/Footer" node_paths=PackedStringArray("resize_control") instance=ExtResource("4_2r8a8")] +custom_minimum_size = Vector2(25, 25) +layout_mode = 2 +resize_control = NodePath("../../..") + +[node name="SidePad2" type="CenterContainer" parent="MainBox/Footer"] +custom_minimum_size = Vector2(2, 2.08165e-12) +layout_mode = 2 + +[node name="BottomPad2" type="CenterContainer" parent="MainBox"] +custom_minimum_size = Vector2(2.08165e-12, 2) +layout_mode = 2 diff --git a/addons/gut/gui/OutputText.gd b/addons/gut/gui/OutputText.gd new file mode 100644 index 0000000..ebd5a4f --- /dev/null +++ b/addons/gut/gui/OutputText.gd @@ -0,0 +1,352 @@ +@tool +extends VBoxContainer + +var GutEditorGlobals = load('res://addons/gut/gui/editor_globals.gd') +var PanelControls = load('res://addons/gut/gui/panel_controls.gd') + +# ############################################################################## +# Keeps search results from the TextEdit +# ############################################################################## +class TextEditSearcher: + var te : TextEdit + var _last_term = '' + var _last_pos = Vector2(-1, -1) + var _ignore_caret_change = false + + func set_text_edit(which): + te = which + te.caret_changed.connect(_on_caret_changed) + + + func _on_caret_changed(): + if(_ignore_caret_change): + _ignore_caret_change = false + else: + _last_pos = _get_caret(); + + + func _get_caret(): + return Vector2(te.get_caret_column(), te.get_caret_line()) + + + func _set_caret_and_sel(pos, len): + te.set_caret_line(pos.y) + te.set_caret_column(pos.x) + if(len > 0): + te.select(pos.y, pos.x, pos.y, pos.x + len) + + + func _find(term, search_flags): + var pos = _get_caret() + if(term == _last_term): + if(search_flags == 0): + pos = _last_pos + pos.x += 1 + else: + pos = _last_pos + pos.x -= 1 + + var result = te.search(term, search_flags, pos.y, pos.x) +# print('searching from ', pos, ' for "', term, '" = ', result) + if(result.y != -1): + _ignore_caret_change = true + _set_caret_and_sel(result, term.length()) + _last_pos = result + + _last_term = term + + func find_next(term): + _find(term, 0) + + func find_prev(term): + _find(term, te.SEARCH_BACKWARDS) + + +# ############################################################################## +# Start OutputText control code +# ############################################################################## +@onready var _ctrls = { + output = $Output, + settings_bar = $Settings, + use_colors = $Settings/UseColors, + word_wrap = $Settings/WordWrap, + + copy_button = $Toolbar/CopyButton, + clear_button = $Toolbar/ClearButton, + show_search = $Toolbar/ShowSearch, + caret_position = $Toolbar/LblPosition, + + search_bar = { + bar = $Search, + search_term = $Search/SearchTerm, + } +} + +var _sr = TextEditSearcher.new() +var _highlighter : CodeHighlighter +var _font_name = null +var _user_prefs = GutEditorGlobals.user_prefs +var _font_name_pctrl = null +var _font_size_pctrl = null + +var keywords = [ + ['Failed', Color.RED], + ['Passed', Color.GREEN], + ['Pending', Color.YELLOW], + ['Risky', Color.YELLOW], + ['Orphans', Color.YELLOW], + ['WARNING', Color.YELLOW], + ['ERROR', Color.RED], + ['ExpectedError', Color.LIGHT_BLUE], +] + + +# Automatically used when running the OutputText scene from the editor. Changes +# to this method only affect test-running the control through the editor. +func _test_running_setup(): + _ctrls.use_colors.text = 'use colors' + _ctrls.show_search.text = 'search' + _ctrls.word_wrap.text = 'ww' + + set_all_fonts("CourierPrime") + set_font_size(30) + + _ctrls.output.queue_redraw() + load_file('user://.gut_editor.bbcode') + await get_tree().process_frame + + show_search(true) + _ctrls.output.set_caret_line(0) + _ctrls.output.scroll_vertical = 0 + _ctrls.output.caret_changed.connect(_on_caret_changed) + + +func _ready(): + if(get_parent() is SubViewport): + return + + _sr.set_text_edit(_ctrls.output) + _ctrls.use_colors.icon = get_theme_icon('RichTextEffect', 'EditorIcons') + _ctrls.show_search.icon = get_theme_icon('Search', 'EditorIcons') + _ctrls.word_wrap.icon = get_theme_icon('Loop', 'EditorIcons') + + _setup_colors() + _ctrls.use_colors.button_pressed = true + _use_highlighting(true) + + if(get_parent() == get_tree().root): + _test_running_setup() + + _ctrls.settings_bar.visible = false + _add_other_ctrls() + + +func _add_other_ctrls(): + var fname = GutUtils.gut_fonts.DEFAULT_CUSTOM_FONT_NAME + if(_user_prefs != null): + fname = _user_prefs.output_font_name.value + _font_name_pctrl = PanelControls.SelectControl.new('Font', fname, GutUtils.avail_fonts, + "The font, you know, for the text below. Change it, see what it does.") + _font_name_pctrl.changed.connect(_on_font_name_changed) + _font_name_pctrl.label.size_flags_horizontal = SIZE_SHRINK_BEGIN + _ctrls.settings_bar.add_child(_font_name_pctrl) + set_all_fonts(fname) + + var fsize = 30 + if(_user_prefs != null): + fsize = _user_prefs.output_font_size.value + _font_size_pctrl = PanelControls.NumberControl.new('Font Size', fsize , 5, 100, + "The size of 'The Font'.") + _font_size_pctrl.changed.connect(_on_font_size_changed) + _font_size_pctrl.label.size_flags_horizontal = SIZE_SHRINK_BEGIN + _ctrls.settings_bar.add_child(_font_size_pctrl) + set_font_size(fsize) + + +# ------------------ +# Private +# ------------------ + +# Call this after changes in colors and the like to get them to apply. reloads +# the text of the output control. +func _refresh_output(): + var orig_pos = _ctrls.output.scroll_vertical + var text = _ctrls.output.text + + _ctrls.output.text = text + _ctrls.output.scroll_vertical = orig_pos + + +func _create_highlighter(default_color=Color(1, 1, 1, 1)): + var to_return = CodeHighlighter.new() + + to_return.function_color = default_color + to_return.number_color = default_color + to_return.symbol_color = default_color + to_return.member_variable_color = default_color + + for keyword in keywords: + to_return.add_keyword_color(keyword[0], keyword[1]) + + return to_return + + +func _setup_colors(): + _ctrls.output.clear() + _highlighter = _create_highlighter() + _ctrls.output.queue_redraw() + + + +func _use_highlighting(should): + if(should): + _ctrls.output.syntax_highlighter = _highlighter + else: + _ctrls.output.syntax_highlighter = null + _refresh_output() + +# ------------------ +# Events +# ------------------ +func _on_caret_changed(): + var txt = str("line:",_ctrls.output.get_caret_line(), ' col:', _ctrls.output.get_caret_column()) + _ctrls.caret_position.text = str(txt) + +func _on_font_size_changed(): + set_font_size(_font_size_pctrl.value) + if(_user_prefs != null): + _user_prefs.output_font_size.value = _font_size_pctrl.value + _user_prefs.output_font_size.save_it() + +func _on_font_name_changed(): + set_all_fonts(_font_name_pctrl.text) + if(_user_prefs != null): + _user_prefs.output_font_name.value = _font_name_pctrl.text + _user_prefs.output_font_name.save_it() + +func _on_CopyButton_pressed(): + copy_to_clipboard() + +func _on_UseColors_pressed(): + _use_highlighting(_ctrls.use_colors.button_pressed) + +func _on_ClearButton_pressed(): + clear() + +func _on_ShowSearch_pressed(): + show_search(_ctrls.show_search.button_pressed) + +func _on_SearchTerm_focus_entered(): + _ctrls.search_bar.search_term.call_deferred('select_all') + +func _on_SearchNext_pressed(): + _sr.find_next(_ctrls.search_bar.search_term.text) + +func _on_SearchPrev_pressed(): + _sr.find_prev(_ctrls.search_bar.search_term.text) + +func _on_SearchTerm_text_changed(new_text): + if(new_text == ''): + _ctrls.output.deselect() + else: + _sr.find_next(new_text) + +func _on_SearchTerm_text_entered(new_text): + if(Input.is_physical_key_pressed(KEY_SHIFT)): + _sr.find_prev(new_text) + else: + _sr.find_next(new_text) + +func _on_SearchTerm_gui_input(event): + if(event is InputEventKey and !event.pressed and event.keycode == KEY_ESCAPE): + show_search(false) + +func _on_WordWrap_pressed(): + if(_ctrls.word_wrap.button_pressed): + _ctrls.output.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + else: + _ctrls.output.wrap_mode = TextEdit.LINE_WRAPPING_NONE + + _ctrls.output.queue_redraw() + +func _on_settings_pressed(): + _ctrls.settings_bar.visible = $Toolbar/ShowSettings.button_pressed + +# ------------------ +# Public +# ------------------ +func show_search(should): + _ctrls.search_bar.bar.visible = should + if(should): + _ctrls.search_bar.search_term.grab_focus() + _ctrls.search_bar.search_term.select_all() + _ctrls.show_search.button_pressed = should + + +func search(text, start_pos, highlight=true): + return _sr.find_next(text) + + +func copy_to_clipboard(): + var selected = _ctrls.output.get_selected_text() + if(selected != ''): + DisplayServer.clipboard_set(selected) + else: + DisplayServer.clipboard_set(_ctrls.output.text) + + +func clear(): + _ctrls.output.text = '' + + +func _set_font(custom_name, theme_font_name): + var font = GutUtils.gut_fonts.get_font_for_theme_font_name(theme_font_name, custom_name) + _ctrls.output.add_theme_font_override(theme_font_name, font) + + +func set_all_fonts(base_name): + _font_name = GutUtils.nvl(base_name, 'Default') + + _set_font(base_name, 'font') + _set_font(base_name, 'normal_font') + _set_font(base_name, 'bold_font') + _set_font(base_name, 'italics_font') + _set_font(base_name, 'bold_italics_font') + + +func set_font_size(new_size): + _ctrls.output.set("theme_override_font_sizes/font_size", new_size) + + +func set_use_colors(value): + pass + + +func get_use_colors(): + return false; + + +func get_rich_text_edit(): + return _ctrls.output + + +func load_file(path): + var f = FileAccess.open(path, FileAccess.READ) + if(f == null): + return + + var t = f.get_as_text() + f = null # closes file + _ctrls.output.text = t + _ctrls.output.scroll_vertical = _ctrls.output.get_line_count() + _ctrls.output.set_deferred('scroll_vertical', _ctrls.output.get_line_count()) + + +func add_text(text): + if(is_inside_tree()): + _ctrls.output.text += text + + +func scroll_to_line(line): + _ctrls.output.scroll_vertical = line + _ctrls.output.set_caret_line(line) diff --git a/addons/gut/gui/OutputText.gd.uid b/addons/gut/gui/OutputText.gd.uid new file mode 100644 index 0000000..7538795 --- /dev/null +++ b/addons/gut/gui/OutputText.gd.uid @@ -0,0 +1 @@ +uid://cax5phqs8acmu diff --git a/addons/gut/gui/OutputText.tscn b/addons/gut/gui/OutputText.tscn new file mode 100644 index 0000000..b666c15 --- /dev/null +++ b/addons/gut/gui/OutputText.tscn @@ -0,0 +1,121 @@ +[gd_scene load_steps=5 format=3 uid="uid://bqmo4dj64c7yl"] + +[ext_resource type="Script" uid="uid://cax5phqs8acmu" path="res://addons/gut/gui/OutputText.gd" id="1"] +[ext_resource type="Texture2D" uid="uid://bvo0uao7deu0q" path="res://addons/gut/icon.png" id="2_b4xqv"] + +[sub_resource type="DPITexture" id="DPITexture_lygvu"] +_source = " +" + +[sub_resource type="CodeHighlighter" id="CodeHighlighter_8ynmy"] +number_color = Color(1, 1, 1, 1) +symbol_color = Color(1, 1, 1, 1) +function_color = Color(1, 1, 1, 1) +member_variable_color = Color(1, 1, 1, 1) +keyword_colors = { +"ERROR": Color(1, 0, 0, 1), +"ExpectedError": Color(0.6784314, 0.84705883, 0.9019608, 1), +"Failed": Color(1, 0, 0, 1), +"Orphans": Color(1, 1, 0, 1), +"Passed": Color(0, 1, 0, 1), +"Pending": Color(1, 1, 0, 1), +"Risky": Color(1, 1, 0, 1), +"WARNING": Color(1, 1, 0, 1) +} + +[node name="OutputText" type="VBoxContainer"] +offset_right = 862.0 +offset_bottom = 523.0 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1") + +[node name="Toolbar" type="HBoxContainer" parent="."] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ShowSearch" type="Button" parent="Toolbar"] +layout_mode = 2 +tooltip_text = "Search" +toggle_mode = true +icon = ExtResource("2_b4xqv") + +[node name="ShowSettings" type="Button" parent="Toolbar"] +layout_mode = 2 +tooltip_text = "Settings" +toggle_mode = true +text = "..." + +[node name="CenterContainer" type="CenterContainer" parent="Toolbar"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="LblPosition" type="Label" parent="Toolbar"] +layout_mode = 2 + +[node name="CopyButton" type="Button" parent="Toolbar"] +layout_mode = 2 +text = " Copy " + +[node name="ClearButton" type="Button" parent="Toolbar"] +layout_mode = 2 +text = " Clear " + +[node name="Settings" type="HBoxContainer" parent="."] +visible = false +layout_mode = 2 + +[node name="WordWrap" type="Button" parent="Settings"] +layout_mode = 2 +tooltip_text = "Word Wrap" +toggle_mode = true +icon = SubResource("DPITexture_lygvu") + +[node name="UseColors" type="Button" parent="Settings"] +layout_mode = 2 +tooltip_text = "Colorized Text" +toggle_mode = true +button_pressed = true +icon = SubResource("DPITexture_lygvu") + +[node name="Output" type="TextEdit" parent="."] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_font_sizes/font_size = 30 +deselect_on_focus_loss_enabled = false +virtual_keyboard_enabled = false +middle_mouse_paste_enabled = false +scroll_smooth = true +syntax_highlighter = SubResource("CodeHighlighter_8ynmy") +highlight_all_occurrences = true +highlight_current_line = true + +[node name="Search" type="HBoxContainer" parent="."] +visible = false +layout_mode = 2 + +[node name="SearchTerm" type="LineEdit" parent="Search"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="SearchNext" type="Button" parent="Search"] +layout_mode = 2 +text = "Next" + +[node name="SearchPrev" type="Button" parent="Search"] +layout_mode = 2 +text = "Prev" + +[connection signal="pressed" from="Toolbar/ShowSearch" to="." method="_on_ShowSearch_pressed"] +[connection signal="pressed" from="Toolbar/ShowSettings" to="." method="_on_settings_pressed"] +[connection signal="pressed" from="Toolbar/CopyButton" to="." method="_on_CopyButton_pressed"] +[connection signal="pressed" from="Toolbar/ClearButton" to="." method="_on_ClearButton_pressed"] +[connection signal="pressed" from="Settings/WordWrap" to="." method="_on_WordWrap_pressed"] +[connection signal="pressed" from="Settings/UseColors" to="." method="_on_UseColors_pressed"] +[connection signal="focus_entered" from="Search/SearchTerm" to="." method="_on_SearchTerm_focus_entered"] +[connection signal="gui_input" from="Search/SearchTerm" to="." method="_on_SearchTerm_gui_input"] +[connection signal="text_changed" from="Search/SearchTerm" to="." method="_on_SearchTerm_text_changed"] +[connection signal="text_submitted" from="Search/SearchTerm" to="." method="_on_SearchTerm_text_entered"] +[connection signal="pressed" from="Search/SearchNext" to="." method="_on_SearchNext_pressed"] +[connection signal="pressed" from="Search/SearchPrev" to="." method="_on_SearchPrev_pressed"] diff --git a/addons/gut/gui/ResizeHandle.gd b/addons/gut/gui/ResizeHandle.gd new file mode 100644 index 0000000..a79f6e3 --- /dev/null +++ b/addons/gut/gui/ResizeHandle.gd @@ -0,0 +1,107 @@ +@tool +extends ColorRect +# ############################################################################# +# Resize Handle control. Place onto a control. Set the orientation, then +# set the control that this should resize. Then you can resize the control +# by dragging this thing around. It's pretty neat. +# ############################################################################# +enum ORIENTATION { + LEFT, + RIGHT +} + +@export var orientation := ORIENTATION.RIGHT : + get: return orientation + set(val): + orientation = val + queue_redraw() +@export var resize_control : Control = null +@export var vertical_resize := true + +var _line_width = .5 +var _line_color = Color(.4, .4, .4) +var _active_line_color = Color(.3, .3, .3) +var _invalid_line_color = Color(1, 0, 0) + +var _line_space = 3 +var _num_lines = 8 + +var _mouse_down = false +# Called when the node enters the scene tree for the first time. + + +func _draw(): + var c = _line_color + if(resize_control == null): + c = _invalid_line_color + elif(_mouse_down): + c = _active_line_color + + if(orientation == ORIENTATION.LEFT): + _draw_resize_handle_left(c) + else: + _draw_resize_handle_right(c) + + +func _gui_input(event): + if(resize_control == null): + return + + if(orientation == ORIENTATION.LEFT): + _handle_left_input(event) + else: + _handle_right_input(event) + + +# Draw the lines in the corner to show where you can +# drag to resize the dialog +func _draw_resize_handle_right(draw_color): + var br = size + + for i in range(_num_lines): + var start = br - Vector2(i * _line_space, 0) + var end = br - Vector2(0, i * _line_space) + draw_line(start, end, draw_color, _line_width, true) + + +func _draw_resize_handle_left(draw_color): + var bl = Vector2(0, size.y) + + for i in range(_num_lines): + var start = bl + Vector2(i * _line_space, 0) + var end = bl - Vector2(0, i * _line_space) + draw_line(start, end, draw_color, _line_width, true) + + +func _handle_right_input(event : InputEvent): + if(event is InputEventMouseMotion): + if(_mouse_down and + event.global_position.x > 0 and + event.global_position.y < DisplayServer.window_get_size().y): + + if(vertical_resize): + resize_control.size.y += event.relative.y + resize_control.size.x += event.relative.x + elif(event is InputEventMouseButton): + if(event.button_index == MOUSE_BUTTON_LEFT): + _mouse_down = event.pressed + queue_redraw() + + +func _handle_left_input(event : InputEvent): + if(event is InputEventMouseMotion): + if(_mouse_down and + event.global_position.x > 0 and + event.global_position.y < DisplayServer.window_get_size().y): + + var start_size = resize_control.size + resize_control.size.x -= event.relative.x + if(resize_control.size.x != start_size.x): + resize_control.global_position.x += event.relative.x + + if(vertical_resize): + resize_control.size.y += event.relative.y + elif(event is InputEventMouseButton): + if(event.button_index == MOUSE_BUTTON_LEFT): + _mouse_down = event.pressed + queue_redraw() diff --git a/addons/gut/gui/ResizeHandle.gd.uid b/addons/gut/gui/ResizeHandle.gd.uid new file mode 100644 index 0000000..a537485 --- /dev/null +++ b/addons/gut/gui/ResizeHandle.gd.uid @@ -0,0 +1 @@ +uid://duf6rfdqr6yoc diff --git a/addons/gut/gui/ResizeHandle.tscn b/addons/gut/gui/ResizeHandle.tscn new file mode 100644 index 0000000..b13d9e5 --- /dev/null +++ b/addons/gut/gui/ResizeHandle.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=2 format=3 uid="uid://bvrqqgjpyouse"] + +[ext_resource type="Script" uid="uid://duf6rfdqr6yoc" path="res://addons/gut/gui/ResizeHandle.gd" id="1_oi5ed"] + +[node name="ResizeHandle" type="ColorRect"] +custom_minimum_size = Vector2(20, 20) +color = Color(1, 1, 1, 0) +script = ExtResource("1_oi5ed") diff --git a/addons/gut/gui/ResultsTree.gd b/addons/gut/gui/ResultsTree.gd new file mode 100644 index 0000000..45e5f28 --- /dev/null +++ b/addons/gut/gui/ResultsTree.gd @@ -0,0 +1,368 @@ +@tool +extends Tree + +var _show_orphans = true +var show_orphans = true : + get: return _show_orphans + set(val): _show_orphans = val + + +var _hide_passing = true +var hide_passing = true : + get: return _hide_passing + set(val): _hide_passing = val + + +var _icons = { + red = load('res://addons/gut/images/red.png'), + green = load('res://addons/gut/images/green.png'), + yellow = load('res://addons/gut/images/yellow.png'), +} + +@export var script_entry_color : Color = Color(0, 0, 0, .2) : + set(val): + if(val != null): + script_entry_color = val +@export var column_0_color : Color = Color(1, 1, 1, 0) : + set(val): + if(val != null): + column_0_color = val +@export var column_1_color : Color = Color(0, 0, 0, .2): + set(val): + if(val != null): + column_1_color = val + + + +var _max_icon_width = 10 +var _root : TreeItem + + +@onready var lbl_overlay = $TextOverlay + + +signal selected(script_path, inner_class, test_name, line_number) + +func _debug_ready(): + hide_passing = false + load_json_file('user://gut_temp_directory/gut_editor.json') + + +func _ready(): + _root = create_item() + set_hide_root(true) + columns = 2 + set_column_expand(0, true) + set_column_expand_ratio(0, 5) + + set_column_expand_ratio(1, 1) + set_column_expand(1, true) + + item_selected.connect(_on_tree_item_selected) + + if(get_parent() == get_tree().root): + _debug_ready() + + +# ------------------- +# Private +# ------------------- +func _get_line_number_from_assert_msg(msg): + var line = -1 + if(msg.find('at line') > 0): + line = msg.split("at line")[-1].split(" ")[-1].to_int() + return line + + +func _get_path_and_inner_class_name_from_test_path(path): + var to_return = { + path = '', + inner_class = '' + } + + to_return.path = path + if !path.ends_with('.gd'): + var loc = path.find('.gd') + to_return.inner_class = path.split('.')[-1] + to_return.path = path.substr(0, loc + 3) + return to_return + + +func _find_script_item_with_path(path): + var items = _root.get_children() + var to_return = null + + var idx = 0 + while(idx < items.size() and to_return == null): + var item = items[idx] + if(item.get_metadata(0).path == path): + to_return = item + else: + idx += 1 + + return to_return + + +func _add_script_tree_item(script_path, script_json): + var path_info = _get_path_and_inner_class_name_from_test_path(script_path) + var item_text = script_path + var parent = _root + + if(path_info.inner_class != ''): + parent = _find_script_item_with_path(path_info.path) + item_text = path_info.inner_class + if(parent == null): + parent = _add_script_tree_item(path_info.path, {}) + + var item = create_item(parent) + item.set_text(0, item_text) + var meta = { + "type":"script", + "path":path_info.path, + "inner_class":path_info.inner_class, + "json":script_json, + "inner_passing":0, + "inner_tests":0 + } + item.set_metadata(0, meta) + item.set_custom_bg_color(0, script_entry_color) + item.set_custom_bg_color(1, script_entry_color) + + return item + + +func _add_assert_item(text, icon, parent_item): + # print(' * adding assert') + var assert_item = create_item(parent_item) + assert_item.set_icon_max_width(0, _max_icon_width) + assert_item.set_text(0, text) + assert_item.set_metadata(0, {"type":"assert"}) + assert_item.set_icon(0, icon) + assert_item.set_custom_bg_color(0, column_0_color) + assert_item.set_custom_bg_color(1, column_1_color) + + return assert_item + + +func _add_test_tree_item(test_name, test_json, script_item): + # print(' * adding test ', test_name) + var no_orphans_to_show = !_show_orphans or (_show_orphans and test_json.orphan_count == 0) + if(_hide_passing and test_json['status'] == 'pass' and no_orphans_to_show): + return + + var item = create_item(script_item) + var status = test_json['status'] + var meta = {"type":"test", "json":test_json} + + item.set_text(0, test_name) + item.set_text(1, status) + item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT) + item.set_custom_bg_color(1, column_1_color) + + item.set_metadata(0, meta) + item.set_icon_max_width(0, _max_icon_width) + item.set_custom_bg_color(0, column_0_color) + + if(status == 'pass' and no_orphans_to_show): + item.set_icon(0, _icons.green) + elif(status == 'fail'): + item.set_icon(0, _icons.red) + else: + item.set_icon(0, _icons.yellow) + + if(!_hide_passing): + for passing in test_json.passing: + _add_assert_item('pass: ' + passing, _icons.green, item) + + for failure in test_json.failing: + _add_assert_item("fail: " + failure.replace("\n", ''), _icons.red, item) + + for pending in test_json.pending: + _add_assert_item("pending: " + pending.replace("\n", ''), _icons.yellow, item) + + var orphan_text = 'orphans' + if(test_json.orphan_count == 1): + orphan_text = 'orphan' + orphan_text = str(int(test_json.orphan_count), ' ', orphan_text) + + if(!no_orphans_to_show): + var orphan_item = _add_assert_item(orphan_text, _icons.yellow, item) + for o in test_json.orphans: + var orphan_entry = create_item(orphan_item) + orphan_entry.set_text(0, o) + orphan_entry.set_custom_bg_color(0, column_0_color) + orphan_entry.set_custom_bg_color(1, column_1_color) + + return item + + +func _add_script_to_tree(key, script_json): + var tests = script_json['tests'] + var test_keys = tests.keys() + var s_item = _add_script_tree_item(key, script_json) + var bad_count = 0 + + for test_key in test_keys: + var t_item = _add_test_tree_item(test_key, tests[test_key], s_item) + if(tests[test_key].status != 'pass'): + bad_count += 1 + elif(t_item != null): + t_item.collapsed = true + + if(s_item.get_children().size() == 0): + if(script_json.props.skipped): + _add_assert_item("Skipped", _icons.yellow, s_item) + s_item.set_text(1, "Skipped") + else: + s_item.free() + else: + var total_text = str('All ', test_keys.size(), ' passed') + if(bad_count == 0): + s_item.collapsed = true + else: + total_text = str(int(test_keys.size() - bad_count), '/', int(test_keys.size()), ' passed') + s_item.set_text(1, total_text) + + +func _free_childless_scripts(): + var items = _root.get_children() + for item in items: + var next_item = item.get_next() + if(item.get_children().size() == 0): + item.free() + item = next_item + + +func _show_all_passed(): + if(_root.get_children().size() == 0): + add_centered_text('Everything passed!') + + +func _load_result_tree(j): + var scripts = j['test_scripts']['scripts'] + var script_keys = scripts.keys() + # if we made it here, the json is valid and we did something, otherwise the + # 'nothing to see here' should be visible. + clear_centered_text() + + var add_count = 0 + for key in script_keys: + add_count += 1 + _add_script_to_tree(key, scripts[key]) + + _free_childless_scripts() + if(add_count == 0): + add_centered_text('Nothing was run') + else: + _show_all_passed() +# ------------------- +# Events +# ------------------- +func _on_tree_item_selected(): + var item = get_selected() + var item_meta = item.get_metadata(0) + var item_type = null + + # Only select the left side of the tree item, cause I like that better. + # you can still click the right, but only the left gets highlighted. + if(item.is_selected(1)): + item.deselect(1) + item.select(0) + + if(item_meta == null): + return + else: + item_type = item_meta.type + + var script_path = ''; + var line = -1; + var test_name = '' + var inner_class = '' + + if(item_type == 'test'): + var s_item = item.get_parent() + script_path = s_item.get_metadata(0)['path'] + inner_class = s_item.get_metadata(0)['inner_class'] + line = -1 + test_name = item.get_text(0) + elif(item_type == 'assert'): + var s_item = item.get_parent().get_parent() + script_path = s_item.get_metadata(0)['path'] + inner_class = s_item.get_metadata(0)['inner_class'] + line = _get_line_number_from_assert_msg(item.get_text(0)) + test_name = item.get_parent().get_text(0) + elif(item_type == 'script'): + script_path = item.get_metadata(0)['path'] + if(item.get_parent() != _root): + inner_class = item.get_text(0) + line = -1 + test_name = '' + else: + return + + selected.emit(script_path, inner_class, test_name, line) + + +# ------------------- +# Public +# ------------------- +func load_json_file(path): + var file = FileAccess.open(path, FileAccess.READ) + var text = '' + if(file != null): + text = file.get_as_text() + + if(text != ''): + var test_json_conv = JSON.new() + var result = test_json_conv.parse(text) + if(result != OK): + add_centered_text(str(path, " has invalid json in it \n", + 'Error ', result, "@", test_json_conv.get_error_line(), "\n", + test_json_conv.get_error_message())) + return + + var data = test_json_conv.get_data() + load_json_results(data) + else: + add_centered_text(str(path, ' was empty or does not exist.')) + + +func load_json_results(j): + clear() + if(_root == null): + _root = create_item() + + _load_result_tree(j) + + +#func clear(): + #clear() + #_root = create_item() + + +func set_summary_min_width(width): + set_column_custom_minimum_width(1, width) + + +func add_centered_text(t): + lbl_overlay.visible = true + lbl_overlay.text = t + + +func clear_centered_text(): + lbl_overlay.visible = false + lbl_overlay.text = '' + + +func collapse_all(): + set_collapsed_on_all(_root, true) + + +func expand_all(): + set_collapsed_on_all(_root, false) + + +func set_collapsed_on_all(item, value): + item.set_collapsed_recursive(value) + if(item == _root and value): + item.set_collapsed(false) diff --git a/addons/gut/gui/ResultsTree.gd.uid b/addons/gut/gui/ResultsTree.gd.uid new file mode 100644 index 0000000..dee3727 --- /dev/null +++ b/addons/gut/gui/ResultsTree.gd.uid @@ -0,0 +1 @@ +uid://dehdhn78qv5tr diff --git a/addons/gut/gui/ResultsTree.tscn b/addons/gut/gui/ResultsTree.tscn new file mode 100644 index 0000000..bc17425 --- /dev/null +++ b/addons/gut/gui/ResultsTree.tscn @@ -0,0 +1,34 @@ +[gd_scene load_steps=2 format=3 uid="uid://dls5r5f6157nq"] + +[ext_resource type="Script" uid="uid://dehdhn78qv5tr" path="res://addons/gut/gui/ResultsTree.gd" id="1_b4uub"] + +[node name="ResultsTree" type="Tree"] +offset_right = 1082.0 +offset_bottom = 544.0 +size_flags_horizontal = 3 +size_flags_vertical = 3 +columns = 2 +hide_root = true +script = ExtResource("1_b4uub") + +[node name="TextOverlay" type="Label" parent="."] +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="ResultsTree" type="VBoxContainer" parent="."] +custom_minimum_size = Vector2(10, 10) +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_right = -70.0 +offset_bottom = -104.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 diff --git a/addons/gut/gui/RunAtCursor.gd b/addons/gut/gui/RunAtCursor.gd new file mode 100644 index 0000000..57475df --- /dev/null +++ b/addons/gut/gui/RunAtCursor.gd @@ -0,0 +1,171 @@ +@tool +extends Control + +var EditorCaretContextNotifier = load('res://addons/gut/editor_caret_context_notifier.gd') + +@onready var _ctrls = { + btn_script = $HBox/BtnRunScript, + btn_inner = $HBox/BtnRunInnerClass, + btn_method = $HBox/BtnRunMethod, + lbl_none = $HBox/LblNoneSelected, + arrow_1 = $HBox/Arrow1, + arrow_2 = $HBox/Arrow2 +} + +var _caret_notifier = null + +var _last_info = { + script = null, + inner_class = null, + method = null +} + +var disabled = false : + set(val): + disabled = val + if(is_inside_tree()): + _ctrls.btn_script.disabled = val + _ctrls.btn_inner.disabled = val + _ctrls.btn_method.disabled = val +var method_prefix = 'test_' +var inner_class_prefix = 'Test' +var menu_manager = null : + set(val): + menu_manager = val + menu_manager.run_script.connect(_on_BtnRunScript_pressed) + menu_manager.run_at_cursor.connect(run_at_cursor) + menu_manager.rerun.connect(rerun) + menu_manager.run_inner_class.connect(_on_BtnRunInnerClass_pressed) + menu_manager.run_test.connect(_on_BtnRunMethod_pressed) + _update_buttons(_last_info) + + +signal run_tests(what) + + +func _ready(): + _ctrls.lbl_none.visible = true + _ctrls.btn_script.visible = false + _ctrls.btn_inner.visible = false + _ctrls.btn_method.visible = false + _ctrls.arrow_1.visible = false + _ctrls.arrow_2.visible = false + + _caret_notifier = EditorCaretContextNotifier.new() + add_child(_caret_notifier) + _caret_notifier.it_changed.connect(_on_caret_notifer_changed) + + disabled = disabled + + +func _on_caret_notifer_changed(data): + if(data.is_test_script): + _last_info = data + _update_buttons(_last_info) + + +# ---------------- +# Private +# ---------------- + +func _update_buttons(info): + _ctrls.lbl_none.visible = false + _ctrls.btn_script.visible = info.script != null + + if(info.script != null and info.is_test_script): + _ctrls.btn_script.text = info.script.resource_path.get_file() + + _ctrls.btn_inner.visible = info.inner_class != null + _ctrls.arrow_1.visible = info.inner_class != null + _ctrls.btn_inner.text = str(info.inner_class) + _ctrls.btn_inner.tooltip_text = str("Run all tests in Inner-Test-Class ", info.inner_class) + + var is_test_method = info.method != null and info.method.begins_with(method_prefix) + _ctrls.btn_method.visible = is_test_method + _ctrls.arrow_2.visible = is_test_method + if(is_test_method): + _ctrls.btn_method.text = str(info.method) + _ctrls.btn_method.tooltip_text = str("Run test ", info.method) + + if(menu_manager != null): + menu_manager.disable_menu("run_script", info.script == null) + menu_manager.disable_menu("run_inner_class", info.inner_class == null) + menu_manager.disable_menu("run_at_cursor", info.script == null) + menu_manager.disable_menu("run_test", is_test_method) + menu_manager.disable_menu("rerun", _last_run_info == {}) + # The button's new size won't take effect until the next frame. + # This appears to be what was causing the button to not be clickable the + # first time. + _update_size.call_deferred() + + +func _update_size(): + custom_minimum_size.x = _ctrls.btn_method.size.x + _ctrls.btn_method.position.x + +var _last_run_info = {} +func _emit_run_tests(info): + _last_run_info = info.duplicate() + run_tests.emit(info) + +# ---------------- +# Events +# ---------------- +func _on_BtnRunScript_pressed(): + var info = _last_info.duplicate() + info.script = info.script.resource_path.get_file() + info.inner_class = null + info.method = null + _emit_run_tests(info) + + +func _on_BtnRunInnerClass_pressed(): + var info = _last_info.duplicate() + info.script = info.script.resource_path.get_file() + info.method = null + _emit_run_tests(info) + + +func _on_BtnRunMethod_pressed(): + var info = _last_info.duplicate() + info.script = info.script.resource_path.get_file() + _emit_run_tests(info) + + +# ---------------- +# Public +# ---------------- +func rerun(): + if(_last_run_info != {}): + _emit_run_tests(_last_run_info) + + +func run_at_cursor(): + if(_ctrls.btn_method.visible): + _on_BtnRunMethod_pressed() + elif(_ctrls.btn_inner.visible): + _on_BtnRunInnerClass_pressed() + elif(_ctrls.btn_script.visible): + _on_BtnRunScript_pressed() + else: + print("nothing selected") + + +func get_script_button(): + return _ctrls.btn_script + + +func get_inner_button(): + return _ctrls.btn_inner + + +func get_test_button(): + return _ctrls.btn_method + + +func set_inner_class_prefix(value): + _caret_notifier.inner_class_prefix = value + + +func apply_gut_config(gut_config): + _caret_notifier.script_prefix = gut_config.options.prefix + _caret_notifier.script_suffix = gut_config.options.suffix diff --git a/addons/gut/gui/RunAtCursor.gd.uid b/addons/gut/gui/RunAtCursor.gd.uid new file mode 100644 index 0000000..944da03 --- /dev/null +++ b/addons/gut/gui/RunAtCursor.gd.uid @@ -0,0 +1 @@ +uid://c4gmgdl1xwflw diff --git a/addons/gut/gui/RunAtCursor.tscn b/addons/gut/gui/RunAtCursor.tscn new file mode 100644 index 0000000..8fa5886 --- /dev/null +++ b/addons/gut/gui/RunAtCursor.tscn @@ -0,0 +1,60 @@ +[gd_scene load_steps=3 format=3 uid="uid://0yunjxtaa8iw"] + +[ext_resource type="Script" uid="uid://c4gmgdl1xwflw" path="res://addons/gut/gui/RunAtCursor.gd" id="1"] +[ext_resource type="Texture2D" uid="uid://6wra5rxmfsrl" path="res://addons/gut/gui/arrow.png" id="3"] + +[node name="RunAtCursor" type="Control"] +custom_minimum_size = Vector2(510, 0) +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_right = 1.0 +offset_bottom = -527.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1") + +[node name="HBox" type="HBoxContainer" parent="."] +layout_mode = 0 +anchor_right = 1.0 +anchor_bottom = 1.0 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="LblNoneSelected" type="Label" parent="HBox"] +visible = false +layout_mode = 2 +text = "" + +[node name="BtnRunScript" type="Button" parent="HBox"] +layout_mode = 2 +text = "test_test.gd" + +[node name="Arrow1" type="TextureButton" parent="HBox"] +custom_minimum_size = Vector2(24, 0) +layout_mode = 2 +texture_normal = ExtResource("3") +stretch_mode = 3 + +[node name="BtnRunInnerClass" type="Button" parent="HBox"] +layout_mode = 2 +tooltip_text = "Run all tests in Inner-Test-Class TestAssertNe" +text = "TestAssertNe" + +[node name="Arrow2" type="TextureButton" parent="HBox"] +custom_minimum_size = Vector2(24, 0) +layout_mode = 2 +texture_normal = ExtResource("3") +stretch_mode = 3 + +[node name="BtnRunMethod" type="Button" parent="HBox"] +layout_mode = 2 +tooltip_text = "Run test test_fails_with_integers_equal" +text = "test_fails_with_integers_equal" + +[connection signal="pressed" from="HBox/BtnRunScript" to="." method="_on_BtnRunScript_pressed"] +[connection signal="pressed" from="HBox/BtnRunInnerClass" to="." method="_on_BtnRunInnerClass_pressed"] +[connection signal="pressed" from="HBox/BtnRunMethod" to="." method="_on_BtnRunMethod_pressed"] diff --git a/addons/gut/gui/RunExternally.gd b/addons/gut/gui/RunExternally.gd new file mode 100644 index 0000000..d98571b --- /dev/null +++ b/addons/gut/gui/RunExternally.gd @@ -0,0 +1,216 @@ +@tool +extends Control + +# I'm probably going to put this back in later and I don't want to create it +# again. Yeah, yeah, yeah. +# class DotsAnimator: +# var text = '' +# var dot = '.' +# var max_dots = 3 +# var dot_delay = .5 + +# var _anim_text = '' +# var _elapsed_time = 0.0 +# var _cur_dots = 0 + +# func get_animated_text(): +# return _anim_text + +# func add_time(delta): +# _elapsed_time += delta +# if(_elapsed_time > dot_delay): +# _elapsed_time = 0 +# _cur_dots += 1 +# if(_cur_dots > max_dots): +# _cur_dots = 0 + +# _anim_text = text.rpad(text.length() + _cur_dots, dot) + + +var GutEditorGlobals = load('res://addons/gut/gui/editor_globals.gd') + +@onready var btn_kill_it = $BgControl/VBox/Kill +@onready var bg_control = $BgControl + +var _pipe_results = {} +var _debug_mode = false +var _std_thread : Thread +var _escape_regex : RegEx = RegEx.new() +var _text_buffer = '' + +var bottom_panel = null : + set(val): + bottom_panel = val + bottom_panel.resized.connect(_on_bottom_panel_resized) +var blocking_mode = "Blocking" +var additional_arguments = [] +var remove_escape_characters = true +@export var bg_color = Color.WHITE: + set(val): + bg_color = val + if(is_inside_tree()): + bg_control.get("theme_override_styles/panel").bg_color = bg_color + + +func _debug_ready(): + _debug_mode = true + additional_arguments = ['-gselect', 'test_awaiter.gd', '-gconfig', 'res://.gutconfig.json'] # '-gunit_test_name', 'test_can_clear_spies' + blocking_mode = "NonBlocking" + run_tests() + + +func _ready(): + _escape_regex.compile("\\x1b\\[[0-9;]*m") + btn_kill_it.visible = false + + if(get_parent() == get_tree().root): + _debug_ready.call_deferred() + bg_color = bg_color + + +func _process(_delta: float) -> void: + if(_pipe_results != {}): + if(!OS.is_process_running(_pipe_results.pid)): + _end_non_blocking() + + +# ---------- +# Private +# ---------- +func _center_me(): + position = get_parent().size / 2.0 - size / 2.0 + + +func _output_text(text, should_scroll = true): + if(_debug_mode): + print(text) + else: + if(remove_escape_characters): + text = _escape_regex.sub(text, '', true) + + if(bottom_panel != null): + bottom_panel.add_output_text(text) + if(should_scroll): + _scroll_output_pane(-1) + else: + _text_buffer += text + + +func _scroll_output_pane(line): + if(!_debug_mode and bottom_panel != null): + var txt_ctrl = bottom_panel.get_text_output_control().get_rich_text_edit() + if(line == -1): + line = txt_ctrl.get_line_count() + txt_ctrl.scroll_vertical = line + + +func _add_arguments_to_output(): + if(additional_arguments.size() != 0): + _output_text( + str("Run Mode arguments: ", ' '.join(additional_arguments), "\n\n") + ) + + +func _load_json(): + if(_debug_mode): + pass # could load file and print it if we want. + elif(bottom_panel != null): + bottom_panel.load_result_json() + + +func _run_blocking(options): + btn_kill_it.visible = false + var output = [] + await get_tree().create_timer(.1).timeout + + OS.execute(OS.get_executable_path(), options, output, true) + + _output_text(output[0]) + _add_arguments_to_output() + _scroll_output_pane(-1) + + _load_json() + queue_free() + + +func _read_non_blocking_stdio(): + while(OS.is_process_running(_pipe_results.pid)): + while(_pipe_results.stderr.get_length() > 0): + _output_text(_pipe_results.stderr.get_line() + "\n") + + while(_pipe_results.stdio.get_length() > 0): + _output_text(_pipe_results.stdio.get_line() + "\n") + + # without this, things start to lock up. + await get_tree().process_frame + + +func _run_non_blocking(options): + _pipe_results = OS.execute_with_pipe(OS.get_executable_path(), options, false) + _std_thread = Thread.new() + _std_thread.start(_read_non_blocking_stdio) + btn_kill_it.visible = true + + +func _end_non_blocking(): + _add_arguments_to_output() + _scroll_output_pane(-1) + + _load_json() + + _pipe_results = {} + _std_thread.wait_to_finish() + _std_thread = null + queue_free() + if(_debug_mode): + get_tree().quit() + + + +# ---------------- +# Events +# ---------------- +func _on_kill_pressed() -> void: + if(_pipe_results != {} and OS.is_process_running(_pipe_results.pid)): + OS.kill(_pipe_results.pid) + btn_kill_it.visible = false + + +func _on_color_rect_gui_input(event: InputEvent) -> void: + if(event is InputEventMouseMotion): + if(event.button_mask == MOUSE_BUTTON_MASK_LEFT): + position += event.relative + + +func _on_bottom_panel_resized(): + _center_me() + + +# ---------------- +# Public +# ---------------- +func run_tests(): + _center_me() + + var options = ["-s", "res://addons/gut/gut_cmdln.gd", "-graie", "-gdisable_colors", + "-gconfig", GutEditorGlobals.editor_run_gut_config_path] + options.append_array(additional_arguments) + + if(blocking_mode == 'Blocking'): + _run_blocking(options) + else: + _run_non_blocking(options) + + +func get_godot_help(): + _text_buffer = '' + var options = ["--help", "--headless"] + await _run_blocking(options) + return _text_buffer + + +func get_gut_help(): + _text_buffer = '' + var options = ["-s", "res://addons/gut/gut_cmdln.gd", "-gh", "--headless"] + await _run_blocking(options) + return _text_buffer diff --git a/addons/gut/gui/RunExternally.gd.uid b/addons/gut/gui/RunExternally.gd.uid new file mode 100644 index 0000000..2be6465 --- /dev/null +++ b/addons/gut/gui/RunExternally.gd.uid @@ -0,0 +1 @@ +uid://bi8pg352un4om diff --git a/addons/gut/gui/RunExternally.tscn b/addons/gut/gui/RunExternally.tscn new file mode 100644 index 0000000..c72926d --- /dev/null +++ b/addons/gut/gui/RunExternally.tscn @@ -0,0 +1,65 @@ +[gd_scene load_steps=3 format=3 uid="uid://cftcb0e6g7tu1"] + +[ext_resource type="Script" uid="uid://bi8pg352un4om" path="res://addons/gut/gui/RunExternally.gd" id="1_lrqqi"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_haowt"] +bg_color = Color(0.025935074, 0.17817128, 0.30283752, 1) +corner_radius_top_left = 20 +corner_radius_top_right = 20 +corner_radius_bottom_right = 20 +corner_radius_bottom_left = 20 +shadow_size = 5 +shadow_offset = Vector2(7, 7) + +[node name="DoShellOut" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 774.0 +offset_bottom = 260.0 +script = ExtResource("1_lrqqi") +bg_color = Color(0.025935074, 0.17817128, 0.30283752, 1) + +[node name="BgControl" type="Panel" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_styles/panel = SubResource("StyleBoxFlat_haowt") + +[node name="VBox" type="VBoxContainer" parent="BgControl"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Spacer" type="CenterContainer" parent="BgControl/VBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Title" type="Label" parent="BgControl/VBox"] +layout_mode = 2 +text = "Running Tests" +horizontal_alignment = 1 + +[node name="Spacer2" type="CenterContainer" parent="BgControl/VBox"] +visible = false +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Kill" type="Button" parent="BgControl/VBox"] +visible = false +custom_minimum_size = Vector2(200, 50) +layout_mode = 2 +size_flags_horizontal = 4 +text = "Stop" + +[node name="Spacer3" type="CenterContainer" parent="BgControl/VBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[connection signal="gui_input" from="BgControl" to="." method="_on_color_rect_gui_input"] +[connection signal="pressed" from="BgControl/VBox/Kill" to="." method="_on_kill_pressed"] diff --git a/addons/gut/gui/RunResults.gd b/addons/gut/gui/RunResults.gd new file mode 100644 index 0000000..83c9f5e --- /dev/null +++ b/addons/gut/gui/RunResults.gd @@ -0,0 +1,250 @@ +@tool +extends Control + +var GutEditorGlobals = load('res://addons/gut/gui/editor_globals.gd') + +var _interface = null +var _output_control = null + +@onready var _ctrls = { + tree = $VBox/Output/Scroll/Tree, + toolbar = { + toolbar = $VBox/Toolbar, + collapse = $VBox/Toolbar/Collapse, + collapse_all = $VBox/Toolbar/CollapseAll, + expand = $VBox/Toolbar/Expand, + expand_all = $VBox/Toolbar/ExpandAll, + hide_passing = $VBox/Toolbar/HidePassing, + show_script = $VBox/Toolbar/ShowScript, + scroll_output = $VBox/Toolbar/ScrollOutput + } +} + +func _ready(): + if(get_parent() is SubViewport): + return + + var f = null + if ($FontSampler.get_label_settings() == null) : + f = get_theme_default_font() + else : + f = $FontSampler.get_label_settings().font + var s_size = f.get_string_size("000 of 000 passed") + _ctrls.tree.set_summary_min_width(s_size.x) + + _set_toolbutton_icon(_ctrls.toolbar.collapse, 'CollapseTree', 'c') + _set_toolbutton_icon(_ctrls.toolbar.collapse_all, 'CollapseTree', 'c') + _set_toolbutton_icon(_ctrls.toolbar.expand, 'ExpandTree', 'e') + _set_toolbutton_icon(_ctrls.toolbar.expand_all, 'ExpandTree', 'e') + _set_toolbutton_icon(_ctrls.toolbar.show_script, 'Script', 'ss') + _set_toolbutton_icon(_ctrls.toolbar.scroll_output, 'Font', 'so') + + _ctrls.tree.hide_passing = true + _ctrls.toolbar.hide_passing.button_pressed = false + _ctrls.tree.show_orphans = true + _ctrls.tree.selected.connect(_on_item_selected) + + if(get_parent() == get_tree().root): + _test_running_setup() + + call_deferred('_update_min_width') + + +func _test_running_setup(): + _ctrls.tree.hide_passing = true + _ctrls.tree.show_orphans = true + + _ctrls.toolbar.hide_passing.text = '[hp]' + _ctrls.tree.load_json_file(GutEditorGlobals.editor_run_json_results_path) + + +func _set_toolbutton_icon(btn, icon_name, text): + if(Engine.is_editor_hint()): + btn.icon = get_theme_icon(icon_name, 'EditorIcons') + else: + btn.text = str('[', text, ']') + + +func _update_min_width(): + custom_minimum_size.x = _ctrls.toolbar.toolbar.size.x + + +func _open_script_in_editor(path, line_number): + if(_interface == null): + print('Too soon, wait a bit and try again.') + return + + var r = load(path) + if(line_number != null and line_number != -1): + _interface.edit_script(r, line_number) + else: + _interface.edit_script(r) + + if(_ctrls.toolbar.show_script.pressed): + _interface.set_main_screen_editor('Script') + + +# starts at beginning of text edit and searches for each search term, moving +# through the text as it goes; ensuring that, when done, it found the first +# occurance of the last srting that happend after the first occurance of +# each string before it. (Generic way of searching for a method name in an +# inner class that may have be a duplicate of a method name in a different +# inner class) +func _get_line_number_for_seq_search(search_strings, te): + if(te == null): + print("No Text editor to get line number for") + return 0; + + var result = null + var line = Vector2i(0, 0) + var s_flags = 0 + + var i = 0 + var string_found = true + while(i < search_strings.size() and string_found): + result = te.search(search_strings[i], s_flags, line.y, line.x) + if(result.x != -1): + line = result + else: + string_found = false + i += 1 + + return line.y + + +func _goto_code(path, line, method_name='', inner_class =''): + if(_interface == null): + print('going to ', [path, line, method_name, inner_class]) + return + + _open_script_in_editor(path, line) + if(line == -1): + var search_strings = [] + if(inner_class != ''): + search_strings.append(inner_class) + + if(method_name != ''): + search_strings.append(method_name) + + await get_tree().process_frame + line = _get_line_number_for_seq_search(search_strings, + _interface.get_script_editor().get_current_editor().get_base_editor()) + if(line != null and line != -1): + _interface.get_script_editor().goto_line(line) + + +func _goto_output(path, method_name, inner_class): + if(_output_control == null): + return + + var search_strings = [path] + + if(inner_class != ''): + search_strings.append(inner_class) + + if(method_name != ''): + search_strings.append(method_name) + + var line = _get_line_number_for_seq_search(search_strings, _output_control.get_rich_text_edit()) + if(line != null and line != -1): + _output_control.scroll_to_line(line) + + + + +# -------------- +# Events +# -------------- +func _on_Collapse_pressed(): + collapse_selected() + + +func _on_Expand_pressed(): + expand_selected() + + +func _on_CollapseAll_pressed(): + collapse_all() + + +func _on_ExpandAll_pressed(): + expand_all() + + +func _on_Hide_Passing_pressed(): + _ctrls.tree.hide_passing = !_ctrls.toolbar.hide_passing.button_pressed + _ctrls.tree.load_json_file(GutEditorGlobals.editor_run_json_results_path) + + +func _on_item_selected(script_path, inner_class, test_name, line): + if(_ctrls.toolbar.show_script.button_pressed): + _goto_code(script_path, line, test_name, inner_class) + if(_ctrls.toolbar.scroll_output.button_pressed): + _goto_output(script_path, test_name, inner_class) + + + + +# -------------- +# Public +# -------------- +func add_centered_text(t): + _ctrls.tree.add_centered_text(t) + + +func clear_centered_text(): + _ctrls.tree.clear_centered_text() + + +func clear(): + _ctrls.tree.clear() + clear_centered_text() + + +func set_interface(which): + _interface = which + + +func collapse_all(): + _ctrls.tree.collapse_all() + + +func expand_all(): + _ctrls.tree.expand_all() + + +func collapse_selected(): + var item = _ctrls.tree.get_selected() + if(item != null): + _ctrls.tree.set_collapsed_on_all(item, true) + + +func expand_selected(): + var item = _ctrls.tree.get_selected() + if(item != null): + _ctrls.tree.set_collapsed_on_all(item, false) + + +func set_show_orphans(should): + _ctrls.tree.show_orphans = should + + +func set_font(font_name, size): + pass +# var dyn_font = FontFile.new() +# var font_data = FontFile.new() +# font_data.font_path = 'res://addons/gut/fonts/' + font_name + '-Regular.ttf' +# font_data.antialiased = true +# dyn_font.font_data = font_data +# +# _font = dyn_font +# _font.size = size +# _font_size = size + + +func set_output_control(value): + _output_control = value + + +func load_json_results(j): + _ctrls.tree.load_json_results(j) diff --git a/addons/gut/gui/RunResults.gd.uid b/addons/gut/gui/RunResults.gd.uid new file mode 100644 index 0000000..27be8b7 --- /dev/null +++ b/addons/gut/gui/RunResults.gd.uid @@ -0,0 +1 @@ +uid://chnko3073tkcv diff --git a/addons/gut/gui/RunResults.tscn b/addons/gut/gui/RunResults.tscn new file mode 100644 index 0000000..2400168 --- /dev/null +++ b/addons/gut/gui/RunResults.tscn @@ -0,0 +1,109 @@ +[gd_scene load_steps=4 format=3 uid="uid://4gyyn12um08h"] + +[ext_resource type="Script" uid="uid://chnko3073tkcv" path="res://addons/gut/gui/RunResults.gd" id="1"] +[ext_resource type="Texture2D" uid="uid://bvo0uao7deu0q" path="res://addons/gut/icon.png" id="2_1k8e0"] +[ext_resource type="PackedScene" uid="uid://dls5r5f6157nq" path="res://addons/gut/gui/ResultsTree.tscn" id="2_o808v"] + +[node name="RunResults" type="Control"] +custom_minimum_size = Vector2(345, 0) +layout_mode = 3 +anchors_preset = 0 +offset_right = 709.0 +offset_bottom = 321.0 +script = ExtResource("1") + +[node name="VBox" type="VBoxContainer" parent="."] +layout_mode = 0 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Toolbar" type="HBoxContainer" parent="VBox"] +layout_mode = 2 +size_flags_horizontal = 0 + +[node name="Expand" type="Button" parent="VBox/Toolbar"] +visible = false +layout_mode = 2 +icon = ExtResource("2_1k8e0") + +[node name="Collapse" type="Button" parent="VBox/Toolbar"] +visible = false +layout_mode = 2 +icon = ExtResource("2_1k8e0") + +[node name="Sep" type="ColorRect" parent="VBox/Toolbar"] +visible = false +custom_minimum_size = Vector2(2, 0) +layout_mode = 2 + +[node name="LblAll" type="Label" parent="VBox/Toolbar"] +visible = false +layout_mode = 2 +text = "All:" + +[node name="ExpandAll" type="Button" parent="VBox/Toolbar"] +layout_mode = 2 +icon = ExtResource("2_1k8e0") + +[node name="CollapseAll" type="Button" parent="VBox/Toolbar"] +layout_mode = 2 +icon = ExtResource("2_1k8e0") + +[node name="Sep2" type="ColorRect" parent="VBox/Toolbar"] +custom_minimum_size = Vector2(2, 0) +layout_mode = 2 + +[node name="HidePassing" type="CheckBox" parent="VBox/Toolbar"] +layout_mode = 2 +size_flags_horizontal = 4 +text = "Passing" + +[node name="Sep3" type="ColorRect" parent="VBox/Toolbar"] +custom_minimum_size = Vector2(2, 0) +layout_mode = 2 + +[node name="LblSync" type="Label" parent="VBox/Toolbar"] +layout_mode = 2 +text = "Sync:" + +[node name="ShowScript" type="Button" parent="VBox/Toolbar"] +layout_mode = 2 +toggle_mode = true +button_pressed = true +icon = ExtResource("2_1k8e0") + +[node name="ScrollOutput" type="Button" parent="VBox/Toolbar"] +layout_mode = 2 +toggle_mode = true +button_pressed = true +icon = ExtResource("2_1k8e0") + +[node name="Output" type="Panel" parent="VBox"] +self_modulate = Color(1, 1, 1, 0.541176) +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Scroll" type="ScrollContainer" parent="VBox/Output"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Tree" parent="VBox/Output/Scroll" instance=ExtResource("2_o808v")] +layout_mode = 2 + +[node name="FontSampler" type="Label" parent="."] +visible = false +layout_mode = 0 +offset_right = 40.0 +offset_bottom = 14.0 +text = "000 of 000 passed" + +[connection signal="pressed" from="VBox/Toolbar/Expand" to="." method="_on_Expand_pressed"] +[connection signal="pressed" from="VBox/Toolbar/Collapse" to="." method="_on_Collapse_pressed"] +[connection signal="pressed" from="VBox/Toolbar/ExpandAll" to="." method="_on_ExpandAll_pressed"] +[connection signal="pressed" from="VBox/Toolbar/CollapseAll" to="." method="_on_CollapseAll_pressed"] +[connection signal="pressed" from="VBox/Toolbar/HidePassing" to="." method="_on_Hide_Passing_pressed"] diff --git a/addons/gut/gui/Settings.tscn b/addons/gut/gui/Settings.tscn new file mode 100644 index 0000000..d827740 --- /dev/null +++ b/addons/gut/gui/Settings.tscn @@ -0,0 +1,7 @@ +[gd_scene format=3 uid="uid://cvvvtsah38l0e"] + +[node name="Settings" type="VBoxContainer"] +offset_right = 388.0 +offset_bottom = 586.0 +size_flags_horizontal = 3 +size_flags_vertical = 3 diff --git a/addons/gut/gui/ShellOutOptions.gd b/addons/gut/gui/ShellOutOptions.gd new file mode 100644 index 0000000..a101fca --- /dev/null +++ b/addons/gut/gui/ShellOutOptions.gd @@ -0,0 +1,321 @@ +@tool +extends ConfirmationDialog + +const RUN_MODE_EDITOR = 'Editor' +const RUN_MODE_BLOCKING = 'Blocking' +const RUN_MODE_NON_BLOCKING = 'NonBlocking' + +var GutEditorGlobals = load('res://addons/gut/gui/editor_globals.gd') + +@onready var _bad_arg_dialog = $AcceptDialog +@onready var _main_container = $ScrollContainer/VBoxContainer + +var _blurb_style_box = StyleBoxEmpty.new() +var _opt_maker_setup = false +var _arg_vbox : VBoxContainer = null +var _my_ok_button : Button = null + +# Run mode button stuff +var _run_mode_theme = load('res://addons/gut/gui/EditorRadioButton.tres') +var _button_group = ButtonGroup.new() +var _btn_in_editor : Button = null +var _btn_blocking : Button = null +var _btn_non_blocking : Button = null +var _txt_additional_arguments = null +var _btn_godot_help = null +var _btn_gut_help = null + + +var opt_maker = null +var default_path = GutEditorGlobals.run_externally_options_path +# I like this. It holds values loaded/saved which makes for an easy +# reset mechanism. Hit OK; values get written to this object (not the file +# system). Hit Cancel; values are reloaded from this object. Call the +# save/load methods to interact with the file system. +# +# Downside: If the keys/sections in the config file change, this ends up +# preserving old data. So you gotta find a way to clean it out +# somehow. +# Downside solved: Clear the config file at the start of the save method. +var _config_file = ConfigFile.new() + +var _run_mode = RUN_MODE_EDITOR +var run_mode = _run_mode: + set(val): + _run_mode = val + if(is_inside_tree()): + _btn_in_editor.button_pressed = _run_mode == RUN_MODE_EDITOR + if(_btn_in_editor.button_pressed): + _btn_in_editor.pressed.emit() + _btn_blocking.button_pressed = _run_mode == RUN_MODE_BLOCKING + if(_btn_blocking.button_pressed): + _btn_blocking.pressed.emit() + _btn_non_blocking.button_pressed = _run_mode == RUN_MODE_NON_BLOCKING + if(_btn_non_blocking.button_pressed): + _btn_non_blocking.pressed.emit() + get(): + return _run_mode + + +var additional_arguments = '' : + get(): + if(_opt_maker_setup): + return opt_maker.controls.additional_arguments.value + else: + return additional_arguments + + +func _debug_ready(): + popup_centered() + default_path = GutEditorGlobals.temp_directory.path_join('test_external_run_options.cfg') + exclusive = false + + var save_btn = Button.new() + save_btn.text = 'save' + save_btn.pressed.connect(func(): + save_to_file() + print(_config_file.encode_to_text())) + save_btn.position = Vector2(100, 20) + save_btn.size = Vector2(100, 100) + get_tree().root.add_child(save_btn) + + var load_btn = Button.new() + load_btn.text = 'load' + load_btn.pressed.connect(func(): + load_from_file() + print(_config_file.encode_to_text())) + load_btn.position = Vector2(100, 130) + load_btn.size = Vector2(100, 100) + get_tree().root.add_child(load_btn) + + var show_btn = Button.new() + show_btn.text = 'Show' + show_btn.pressed.connect(popup_centered) + show_btn.position = Vector2(100, 250) + show_btn.size = Vector2(100, 100) + get_tree().root.add_child(show_btn) + + +func _ready(): + opt_maker = GutUtils.OptionMaker.new(_main_container) + _add_controls() + + if(get_parent() == get_tree().root): + _debug_ready.call_deferred() + + _my_ok_button = Button.new() + _my_ok_button.text = 'OK' + _my_ok_button.pressed.connect(_validate_and_confirm) + get_ok_button().add_sibling(_my_ok_button) + get_ok_button().modulate.a = 0.0 + get_ok_button().text = '' + get_ok_button().disabled = true + + canceled.connect(reset) + _button_group.pressed.connect(_on_mode_button_pressed) + run_mode = run_mode + + +func _validate_and_confirm(): + if(validate_arguments()): + _save_to_config_file(_config_file) + confirmed.emit() + hide() + else: + var dlg_text = str("Invalid arguments. The following cannot be used:\n", + ' '.join(_invalid_args)) + + if(run_mode == RUN_MODE_BLOCKING): + dlg_text += str("\nThese cannot be used with blocking mode:\n", + ' '.join(_invalid_blocking_args)) + + _bad_arg_dialog.dialog_text = dlg_text + _bad_arg_dialog.popup_centered() + + +func _on_mode_button_pressed(which): + if(which == _btn_in_editor): + _arg_vbox.modulate.a = .3 + else: + _arg_vbox.modulate.a = 1.0 + + _txt_additional_arguments.value_ctrl.editable = which != _btn_in_editor + if(which == _btn_in_editor): + _run_mode = RUN_MODE_EDITOR + elif(which == _btn_blocking): + _run_mode = RUN_MODE_BLOCKING + elif(which == _btn_non_blocking): + _run_mode = RUN_MODE_NON_BLOCKING + + +func _add_run_mode_button(text, desc_label, description): + var btn = Button.new() + btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + btn.toggle_mode = true + btn.text = text + btn.button_group = _button_group + btn.theme = _run_mode_theme + btn.pressed.connect(func(): desc_label.text = str('[b]', text, "[/b]\n", description)) + + return btn + + +func _add_blurb(text): + var ctrl = opt_maker.add_blurb(text) + ctrl.set("theme_override_styles/normal", _blurb_style_box) + return ctrl + + +func _add_title(text): + var ctrl = opt_maker.add_title(text) + ctrl.get_child(0).horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + return ctrl + + +func _add_controls(): + _add_title("Run Modes") + _add_blurb( + "Choose how GUT will launch tests. Normally you just run them through the editor, but now " + + "you can run them externally. This is an experimental feature. It has been tested on Mac " + + "and Windows. Your results may vary. Feedback welcome at [url]https://github.com/bitwes/Gut/issues[/url].\n ") + + var button_desc_box = HBoxContainer.new() + var button_box = VBoxContainer.new() + var button_desc = RichTextLabel.new() + button_desc.fit_content = true + button_desc.bbcode_enabled = true + button_desc.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button_desc.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _main_container.add_child(button_desc_box) + button_desc_box.add_child(button_box) + button_desc_box.add_child(button_desc) + + _btn_in_editor = _add_run_mode_button("In Editor (default)", button_desc, + "This is the default. Runs through the editor. When an error occurs " + + "the debugger is invoked. [b]print[/b] output " + + "appears in the Output panel and errors show up in the Debugger panel.") + button_box.add_child(_btn_in_editor) + _btn_blocking = _add_run_mode_button("Externally - Blocking", button_desc, + "Debugger is not enabled, and cannot be enabled. All output (print, errors, warnings, etc) " + + "appears in the GUT panel, and [b]not[/b] the Output or Debugger panels. \n" + + "The Editor cannot be used while tests are running. If you are trying to test for errors, this " + + "mode provides the best output.") + button_box.add_child(_btn_blocking) + _btn_non_blocking = _add_run_mode_button("Externally - NonBlocking", button_desc, + "Debugger is not enabled, and cannot be enabled. All output (print, errors, warnings, etc) " + + "appears in the GUT panel, and [b]not[/b] the Output or Debugger panels. \n" + + "Test output is streamed to the GUT panel. The editor is not blocked, but can be less " + + "responsive when there is a lot of output. This is the only mode that supports the --headless argument." ) + button_box.add_child(_btn_non_blocking) + + _add_title("Command Line Arguments") + _arg_vbox = VBoxContainer.new() + _main_container.add_child(_arg_vbox) + opt_maker.base_container = _arg_vbox + _txt_additional_arguments = opt_maker.add_value("additional_arguments", additional_arguments, '', '') + _txt_additional_arguments.value_ctrl.placeholder_text = "Put your arguments here. Ex: --verbose -glog 0" + _txt_additional_arguments.value_ctrl.select_all_on_focus = false + _add_blurb( + "Supply any command line options for GUT and/or Godot when running externally. You cannot use " + + "spaces in values. See the Godot and GUT documentation for valid arguments. GUT arguments " + + "specified here take precedence over your config.") + _add_blurb("[b]Be Careful[/b] There are plenty of argument combinations that may make this " + + "act wrong/odd/bad/horrible. Some arguments you might [i]want[/i] " + + "to use but [b]shouldn't[/b] are checked for, but not that many. Choose your arguments carefully (generally good advice).") + + opt_maker.base_container = _main_container + _add_title("Display CLI Help") + _add_blurb("You can use these buttons to get a list of valid GUT and Godot options. They print the CLI help text for each to the [b]Output Panel[/b].") + _btn_godot_help = Button.new() + _btn_godot_help.text = "Print Godot CLI Help" + _main_container.add_child(_btn_godot_help) + _btn_godot_help.pressed.connect(func(): + await _show_help("get_godot_help")) + + _btn_gut_help = Button.new() + _btn_gut_help.text = "Print GUT CLI Help" + _main_container.add_child(_btn_gut_help) + _btn_gut_help.pressed.connect(func(): + await _show_help("get_gut_help")) + + _opt_maker_setup = true + + +func _show_help(help_method_name): + _btn_godot_help.disabled = true + _btn_gut_help.disabled = true + var re = GutUtils.RunExternallyScene.instantiate() + add_child(re) + re.visible = false + var text = await re.call(help_method_name) + print(text) + re.queue_free() + _btn_godot_help.disabled = false + _btn_gut_help.disabled = false + if(GutEditorGlobals.gut_plugin != null): + GutEditorGlobals.gut_plugin.show_output_panel() + + +func _save_to_config_file(f : ConfigFile): + f.clear() + f.set_value('main', 'run_mode', run_mode) + f.set_value('main', 'additional_arguments', opt_maker.controls.additional_arguments.value) + + +func save_to_file(path = default_path): + _save_to_config_file(_config_file) + _config_file.save(path) + + +func _load_from_config_file(f): + run_mode = f.get_value('main', 'run_mode', RUN_MODE_EDITOR) + opt_maker.controls.additional_arguments.value = \ + f.get_value('main', 'additional_arguments', '') + + +func load_from_file(path = default_path): + _config_file.load(path) + _load_from_config_file(_config_file) + + +func reset(): + _load_from_config_file(_config_file) + + +func get_additional_arguments_array(): + return additional_arguments.split(" ", false) + + +func should_run_externally(): + return run_mode != RUN_MODE_EDITOR + + +var _invalid_args = [ + '-d', '--debug', + '-s', '--script', + '-e', '--editor' +] +var _invalid_blocking_args = [ + '--headless' +] +func validate_arguments(): + var arg_array = get_additional_arguments_array() + var i = 0 + var invalid_found = false + while i < _invalid_args.size() and !invalid_found: + if(arg_array.has(_invalid_args[i])): + invalid_found = true + i += 1 + + if(run_mode == RUN_MODE_BLOCKING): + i = 0 + while i < _invalid_blocking_args.size() and !invalid_found: + if(arg_array.has(_invalid_blocking_args[i])): + invalid_found = true + i += 1 + + return !invalid_found + + +func get_godot_help(): + return '' diff --git a/addons/gut/gui/ShellOutOptions.gd.uid b/addons/gut/gui/ShellOutOptions.gd.uid new file mode 100644 index 0000000..22c90ec --- /dev/null +++ b/addons/gut/gui/ShellOutOptions.gd.uid @@ -0,0 +1 @@ +uid://c64u22kybimgi diff --git a/addons/gut/gui/ShellOutOptions.tscn b/addons/gut/gui/ShellOutOptions.tscn new file mode 100644 index 0000000..72442a5 --- /dev/null +++ b/addons/gut/gui/ShellOutOptions.tscn @@ -0,0 +1,34 @@ +[gd_scene load_steps=2 format=3 uid="uid://ckv5eh8xyrwbk"] + +[ext_resource type="Script" uid="uid://c64u22kybimgi" path="res://addons/gut/gui/ShellOutOptions.gd" id="1_ht2pf"] + +[node name="ShellOutOptions" type="ConfirmationDialog"] +oversampling_override = 1.0 +title = "GUT Run Mode (Experimental)" +position = Vector2i(0, 36) +size = Vector2i(516, 557) +visible = true +script = ExtResource("1_ht2pf") + +[node name="ScrollContainer" type="ScrollContainer" parent="."] +custom_minimum_size = Vector2(500, 500) +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 8.0 +offset_top = 8.0 +offset_right = -8.0 +offset_bottom = -49.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="ScrollContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="AcceptDialog" type="AcceptDialog" parent="."] +oversampling_override = 1.0 +size = Vector2i(399, 106) +dialog_text = "Invalid arguments. The following cannot be used: +-d --debug -s --script" diff --git a/addons/gut/gui/ShortcutButton.gd b/addons/gut/gui/ShortcutButton.gd new file mode 100644 index 0000000..85b0de6 --- /dev/null +++ b/addons/gut/gui/ShortcutButton.gd @@ -0,0 +1,154 @@ +@tool +extends Control + + +@onready var _ctrls = { + shortcut_label = $Layout/lblShortcut, + set_button = $Layout/SetButton, + save_button = $Layout/SaveButton, + cancel_button = $Layout/CancelButton, + clear_button = $Layout/ClearButton +} + +signal changed +signal start_edit +signal end_edit + +const NO_SHORTCUT = '' + +var _source_event = InputEventKey.new() +var _pre_edit_event = null +var _key_disp = NO_SHORTCUT +var _editing = false + +var _modifier_keys = [KEY_ALT, KEY_CTRL, KEY_META, KEY_SHIFT] + +# Called when the node enters the scene tree for the first time. +func _ready(): + set_process_unhandled_key_input(false) + + +func _display_shortcut(): + if(_key_disp == ''): + _key_disp = NO_SHORTCUT + _ctrls.shortcut_label.text = _key_disp + + +func _is_shift_only_modifier(): + return _source_event.shift_pressed and \ + !(_source_event.alt_pressed or \ + _source_event.ctrl_pressed or \ + _source_event.meta_pressed) \ + and !_is_modifier(_source_event.keycode) + + +func _has_modifier(event): + return event.alt_pressed or event.ctrl_pressed or \ + event.meta_pressed or event.shift_pressed + + +func _is_modifier(keycode): + return _modifier_keys.has(keycode) + + +func _edit_mode(should): + _editing = should + set_process_unhandled_key_input(should) + _ctrls.set_button.visible = !should + _ctrls.save_button.visible = should + _ctrls.save_button.disabled = should + _ctrls.cancel_button.visible = should + _ctrls.clear_button.visible = !should + + if(should and to_s() == ''): + _ctrls.shortcut_label.text = 'press buttons' + else: + _ctrls.shortcut_label.text = to_s() + + if(should): + emit_signal("start_edit") + else: + emit_signal("end_edit") + +# --------------- +# Events +# --------------- +func _unhandled_key_input(event): + if(event is InputEventKey): + if(event.pressed): + if(_has_modifier(event) and !_is_modifier(event.get_keycode_with_modifiers())): + _source_event = event + _key_disp = OS.get_keycode_string(event.get_keycode_with_modifiers()) + else: + _source_event = InputEventKey.new() + _key_disp = NO_SHORTCUT + _display_shortcut() + _ctrls.save_button.disabled = !is_valid() + + +func _on_SetButton_pressed(): + _pre_edit_event = _source_event.duplicate(true) + _edit_mode(true) + + +func _on_SaveButton_pressed(): + _edit_mode(false) + _pre_edit_event = null + emit_signal('changed') + + +func _on_CancelButton_pressed(): + cancel() + + +func _on_ClearButton_pressed(): + clear_shortcut() + +# --------------- +# Public +# --------------- +func to_s(): + return OS.get_keycode_string(_source_event.get_keycode_with_modifiers()) + + +func is_valid(): + return _has_modifier(_source_event) and !_is_shift_only_modifier() + + +func get_shortcut(): + var to_return = Shortcut.new() + to_return.events.append(_source_event) + return to_return + +func get_input_event(): + return _source_event + +func set_shortcut(sc): + if(sc == null or sc.events == null || sc.events.size() <= 0): + clear_shortcut() + else: + _source_event = sc.events[0] + _key_disp = to_s() + _display_shortcut() + + +func clear_shortcut(): + _source_event = InputEventKey.new() + _key_disp = NO_SHORTCUT + _display_shortcut() + + +func disable_set(should): + _ctrls.set_button.disabled = should + + +func disable_clear(should): + _ctrls.clear_button.disabled = should + + +func cancel(): + if(_editing): + _edit_mode(false) + _source_event = _pre_edit_event + _key_disp = to_s() + _display_shortcut() diff --git a/addons/gut/gui/ShortcutButton.gd.uid b/addons/gut/gui/ShortcutButton.gd.uid new file mode 100644 index 0000000..1e756d8 --- /dev/null +++ b/addons/gut/gui/ShortcutButton.gd.uid @@ -0,0 +1 @@ +uid://k6hvvpekp0xw diff --git a/addons/gut/gui/ShortcutButton.tscn b/addons/gut/gui/ShortcutButton.tscn new file mode 100644 index 0000000..896854a --- /dev/null +++ b/addons/gut/gui/ShortcutButton.tscn @@ -0,0 +1,55 @@ +[gd_scene load_steps=2 format=3 uid="uid://sfb1fw8j6ufu"] + +[ext_resource type="Script" uid="uid://k6hvvpekp0xw" path="res://addons/gut/gui/ShortcutButton.gd" id="1"] + +[node name="ShortcutButton" type="Control"] +custom_minimum_size = Vector2(210, 30) +layout_mode = 3 +anchor_right = 0.123 +anchor_bottom = 0.04 +offset_right = 68.304 +offset_bottom = 6.08 +script = ExtResource("1") + +[node name="Layout" type="HBoxContainer" parent="."] +layout_mode = 0 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="lblShortcut" type="Label" parent="Layout"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 7 +text = "" +horizontal_alignment = 2 + +[node name="CenterContainer" type="CenterContainer" parent="Layout"] +custom_minimum_size = Vector2(10, 0) +layout_mode = 2 + +[node name="SetButton" type="Button" parent="Layout"] +custom_minimum_size = Vector2(60, 0) +layout_mode = 2 +text = "Set" + +[node name="SaveButton" type="Button" parent="Layout"] +visible = false +custom_minimum_size = Vector2(60, 0) +layout_mode = 2 +text = "Save" + +[node name="CancelButton" type="Button" parent="Layout"] +visible = false +custom_minimum_size = Vector2(60, 0) +layout_mode = 2 +text = "Cancel" + +[node name="ClearButton" type="Button" parent="Layout"] +custom_minimum_size = Vector2(60, 0) +layout_mode = 2 +text = "Clear" + +[connection signal="pressed" from="Layout/SetButton" to="." method="_on_SetButton_pressed"] +[connection signal="pressed" from="Layout/SaveButton" to="." method="_on_SaveButton_pressed"] +[connection signal="pressed" from="Layout/CancelButton" to="." method="_on_CancelButton_pressed"] +[connection signal="pressed" from="Layout/ClearButton" to="." method="_on_ClearButton_pressed"] diff --git a/addons/gut/gui/ShortcutDialog.gd b/addons/gut/gui/ShortcutDialog.gd new file mode 100644 index 0000000..a1b9b7e --- /dev/null +++ b/addons/gut/gui/ShortcutDialog.gd @@ -0,0 +1,120 @@ +@tool +extends ConfirmationDialog + +var GutEditorGlobals = load('res://addons/gut/gui/editor_globals.gd') +var default_path = GutEditorGlobals.editor_shortcuts_path + + +@onready var scbtn_run_all = $Scroll/Layout/CRunAll/ShortcutButton +@onready var scbtn_run_current_script = $Scroll/Layout/CRunCurrentScript/ShortcutButton +@onready var scbtn_run_current_inner = $Scroll/Layout/CRunCurrentInner/ShortcutButton +@onready var scbtn_run_current_test = $Scroll/Layout/CRunCurrentTest/ShortcutButton +@onready var scbtn_run_at_cursor = $Scroll/Layout/CRunAtCursor/ShortcutButton +@onready var scbtn_rerun = $Scroll/Layout/CRerun/ShortcutButton +@onready var scbtn_panel = $Scroll/Layout/CPanelButton/ShortcutButton +@onready var scbtn_windowed = $Scroll/Layout/CToggleWindowed/ShortcutButton + + +@onready var all_buttons = [ + scbtn_run_all, scbtn_run_current_script, scbtn_run_current_inner, + scbtn_run_current_test, scbtn_run_at_cursor, scbtn_rerun, + scbtn_panel, scbtn_windowed +] + + +func _debug_ready(): + popup_centered() + + var btn = Button.new() + btn.text = "show" + get_tree().root.add_child(btn) + btn.pressed.connect(popup) + btn.position = Vector2(100, 100) + btn.size = Vector2(100, 100) + + size_changed.connect(func(): title = str(size)) + + +func _ready(): + for scbtn in all_buttons: + scbtn.connect('start_edit', _on_edit_start.bind(scbtn)) + scbtn.connect('end_edit', _on_edit_end) + + canceled.connect(_on_cancel) + + # Sizing this window on different monitors, especially compared to what it + # looks like if you just run this project is annoying. This is what I came + # up with after getting annoyed. You probably won't be looking at this + # very often so it's fine...until it isn't. + size = Vector2(DisplayServer.screen_get_size()) * Vector2(.5, .8) + + if(get_parent() == get_tree().root): + _debug_ready.call_deferred() + + + +func _cancel_all(): + for scbtn in all_buttons: + scbtn.cancel() + + +# ------------ +# Events +# ------------ +func _on_cancel(): + _cancel_all() + load_shortcuts() + + +func _on_edit_start(which): + for scbtn in all_buttons: + if(scbtn != which): + scbtn.disable_set(true) + scbtn.disable_clear(true) + + +func _on_edit_end(): + for scbtn in all_buttons: + scbtn.disable_set(false) + scbtn.disable_clear(false) + + +# ------------ +# Public +# ------------ +func save_shortcuts(): + save_shortcuts_to_file(default_path) + + +func save_shortcuts_to_file(path): + var f = ConfigFile.new() + f.set_value('main', 'panel_button', scbtn_panel.get_shortcut()) + f.set_value('main', 'rerun', scbtn_rerun.get_shortcut()) + f.set_value('main', 'run_all', scbtn_run_all.get_shortcut()) + f.set_value('main', 'run_at_cursor', scbtn_run_at_cursor.get_shortcut()) + f.set_value('main', 'run_current_inner', scbtn_run_current_inner.get_shortcut()) + f.set_value('main', 'run_current_script', scbtn_run_current_script.get_shortcut()) + f.set_value('main', 'run_current_test', scbtn_run_current_test.get_shortcut()) + f.set_value('main', 'toggle_windowed', scbtn_windowed.get_shortcut()) + f.save(path) + + +func load_shortcuts(): + load_shortcuts_from_file(default_path) + + +func load_shortcuts_from_file(path): + var f = ConfigFile.new() + # as long as this shortcut is never modified, this is fine, otherwise + # each thing should get its own default instead. + var empty = Shortcut.new() + + f.load(path) + scbtn_panel.set_shortcut(f.get_value('main', 'panel_button', empty)) + scbtn_rerun.set_shortcut(f.get_value('main', 'rerun', empty)) + scbtn_run_all.set_shortcut(f.get_value('main', 'run_all', empty)) + scbtn_run_at_cursor.set_shortcut(f.get_value('main', 'run_at_cursor', empty)) + scbtn_run_current_inner.set_shortcut(f.get_value('main', 'run_current_inner', empty)) + scbtn_run_current_script.set_shortcut(f.get_value('main', 'run_current_script', empty)) + scbtn_run_current_test.set_shortcut(f.get_value('main', 'run_current_test', empty)) + scbtn_windowed.set_shortcut(f.get_value('main', 'toggle_windowed', empty)) diff --git a/addons/gut/gui/ShortcutDialog.gd.uid b/addons/gut/gui/ShortcutDialog.gd.uid new file mode 100644 index 0000000..7883ae0 --- /dev/null +++ b/addons/gut/gui/ShortcutDialog.gd.uid @@ -0,0 +1 @@ +uid://dc5jgemxslgvl diff --git a/addons/gut/gui/ShortcutDialog.tscn b/addons/gut/gui/ShortcutDialog.tscn new file mode 100644 index 0000000..8bb818e --- /dev/null +++ b/addons/gut/gui/ShortcutDialog.tscn @@ -0,0 +1,207 @@ +[gd_scene load_steps=3 format=3 uid="uid://dj5ve0bq7xa5j"] + +[ext_resource type="Script" uid="uid://dc5jgemxslgvl" path="res://addons/gut/gui/ShortcutDialog.gd" id="1_qq8qn"] +[ext_resource type="PackedScene" uid="uid://sfb1fw8j6ufu" path="res://addons/gut/gui/ShortcutButton.tscn" id="2_i3wie"] + +[node name="ShortcutDialog" type="ConfirmationDialog"] +oversampling_override = 1.0 +title = "GUT Shortcuts" +position = Vector2i(0, 36) +size = Vector2i(1920, 1728) +visible = true +script = ExtResource("1_qq8qn") + +[node name="Scroll" type="ScrollContainer" parent="."] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 1912.0 +offset_bottom = 1679.0 + +[node name="Layout" type="VBoxContainer" parent="Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="ShortcutDescription" type="RichTextLabel" parent="Scroll/Layout"] +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 +bbcode_enabled = true +text = "Shortcuts for: + - Buttons in Panel + - Project->Tools->GUT menu items +Shortcuts that only apply to menus are labeled." +fit_content = true +scroll_active = false + +[node name="TopPad" type="CenterContainer" parent="Scroll/Layout"] +custom_minimum_size = Vector2(0, 5) +layout_mode = 2 + +[node name="CPanelButton" type="HBoxContainer" parent="Scroll/Layout"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Scroll/Layout/CPanelButton"] +custom_minimum_size = Vector2(50, 0) +layout_mode = 2 +size_flags_vertical = 7 +text = "Show/Hide GUT" + +[node name="ShortcutButton" parent="Scroll/Layout/CPanelButton" instance=ExtResource("2_i3wie")] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ShortcutDescription2" type="RichTextLabel" parent="Scroll/Layout"] +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 +bbcode_enabled = true +text = "[i]Show/hide the gut panel or move focus to/away from the GUT window. +[/i]" +fit_content = true +scroll_active = false + +[node name="CRunAll" type="HBoxContainer" parent="Scroll/Layout"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Scroll/Layout/CRunAll"] +custom_minimum_size = Vector2(50, 0) +layout_mode = 2 +size_flags_vertical = 7 +text = "Run All" + +[node name="ShortcutButton" parent="Scroll/Layout/CRunAll" instance=ExtResource("2_i3wie")] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ShortcutDescription3" type="RichTextLabel" parent="Scroll/Layout"] +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 +bbcode_enabled = true +text = "[i]Run the entire test suite.[/i]" +fit_content = true +scroll_active = false + +[node name="CRunCurrentScript" type="HBoxContainer" parent="Scroll/Layout"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Scroll/Layout/CRunCurrentScript"] +custom_minimum_size = Vector2(50, 0) +layout_mode = 2 +size_flags_vertical = 7 +text = "Run Current Script" + +[node name="ShortcutButton" parent="Scroll/Layout/CRunCurrentScript" instance=ExtResource("2_i3wie")] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ShortcutDescription4" type="RichTextLabel" parent="Scroll/Layout"] +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 +bbcode_enabled = true +text = "[i]Run all tests in the currently selected script.[/i]" +fit_content = true +scroll_active = false + +[node name="CRunCurrentInner" type="HBoxContainer" parent="Scroll/Layout"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Scroll/Layout/CRunCurrentInner"] +custom_minimum_size = Vector2(50, 0) +layout_mode = 2 +size_flags_vertical = 7 +text = "Run Current Inner Class" + +[node name="ShortcutButton" parent="Scroll/Layout/CRunCurrentInner" instance=ExtResource("2_i3wie")] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ShortcutDescription5" type="RichTextLabel" parent="Scroll/Layout"] +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 +bbcode_enabled = true +text = "[i]Run only the currently selected inner test class if one is selected.[/i]" +fit_content = true +scroll_active = false + +[node name="CRunCurrentTest" type="HBoxContainer" parent="Scroll/Layout"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Scroll/Layout/CRunCurrentTest"] +custom_minimum_size = Vector2(50, 0) +layout_mode = 2 +size_flags_vertical = 7 +text = "Run Current Test" + +[node name="ShortcutButton" parent="Scroll/Layout/CRunCurrentTest" instance=ExtResource("2_i3wie")] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ShortcutDescription6" type="RichTextLabel" parent="Scroll/Layout"] +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 +bbcode_enabled = true +text = "[i]Run only the currently selected test, if one is selected[/i]" +fit_content = true +scroll_active = false + +[node name="CRunAtCursor" type="HBoxContainer" parent="Scroll/Layout"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Scroll/Layout/CRunAtCursor"] +custom_minimum_size = Vector2(50, 0) +layout_mode = 2 +size_flags_vertical = 7 +text = "Run At Cursor (menu only)" + +[node name="ShortcutButton" parent="Scroll/Layout/CRunAtCursor" instance=ExtResource("2_i3wie")] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ShortcutDescription7" type="RichTextLabel" parent="Scroll/Layout"] +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 +bbcode_enabled = true +text = "[i]Run the most specific test/inner class/script based on where the cursor is.[/i]" +fit_content = true +scroll_active = false + +[node name="CRerun" type="HBoxContainer" parent="Scroll/Layout"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Scroll/Layout/CRerun"] +custom_minimum_size = Vector2(50, 0) +layout_mode = 2 +size_flags_vertical = 7 +text = "Rerun (menu only)" + +[node name="ShortcutButton" parent="Scroll/Layout/CRerun" instance=ExtResource("2_i3wie")] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ShortcutDescription8" type="RichTextLabel" parent="Scroll/Layout"] +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 +bbcode_enabled = true +text = "[i]Rerun the test(s) that were last run." +fit_content = true +scroll_active = false + +[node name="CToggleWindowed" type="HBoxContainer" parent="Scroll/Layout"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Scroll/Layout/CToggleWindowed"] +custom_minimum_size = Vector2(50, 0) +layout_mode = 2 +size_flags_vertical = 7 +text = "Toggle Windowed" + +[node name="ShortcutButton" parent="Scroll/Layout/CToggleWindowed" instance=ExtResource("2_i3wie")] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ShortcutDescription9" type="RichTextLabel" parent="Scroll/Layout"] +custom_minimum_size = Vector2(0, 20) +layout_mode = 2 +bbcode_enabled = true +text = "[i]Toggle GUT in the bottom panel or a separate window.[/i]" +fit_content = true +scroll_active = false diff --git a/addons/gut/gui/about.gd b/addons/gut/gui/about.gd new file mode 100644 index 0000000..5094e14 --- /dev/null +++ b/addons/gut/gui/about.gd @@ -0,0 +1,125 @@ +@tool +extends AcceptDialog + +var GutEditorGlobals = load('res://addons/gut/gui/editor_globals.gd') + +var _bbcode = \ +""" +[center]GUT {gut_version}[/center] + +[center][b]GUT Links[/b] +{gut_link_table}[/center] + +[center][b]VSCode Extension Links[/b] +{vscode_link_table}[/center] + +[center]You can support GUT development at +{donate_link} + +Thanks for using GUT! +[/center] +""" + +var _gut_links = [ + [&"Documentation", &"https://gut.readthedocs.io"], + [&"What's New", &"https://github.com/bitwes/Gut/releases/tag/v{gut_version}"], + [&"Repo", &"https://github.com/bitwes/gut"], + [&"Report Bugs", &"https://github.com/bitwes/gut/issues"] +] + +var _vscode_links = [ + ["Repo", "https://github.com/bitwes/gut-extension"], + ["Market Place", "https://marketplace.visualstudio.com/items?itemName=bitwes.gut-extension"] +] + +var _donate_link = "https://buymeacoffee.com/bitwes" + +@onready var _logo = $Logo + + +func _ready(): + if(get_parent() is SubViewport): + return + + _vert_center_logo() + $Logo.disabled = true + $HBox/Scroll/RichTextLabel.text = _make_text() + + +func _color_link(link_text): + return str("[color=ROYAL_BLUE]", link_text, "[/color]") + + +func _link_table(entries): + var text = '' + for entry in entries: + text += str("[cell][right]", entry[0], "[/right][/cell]") + var link = str("[url]", entry[1], "[/url]") + if(entry[1].length() > 60): + link = str("[url=", entry[1], "]", entry[1].substr(0, 50), "...[/url]") + + text += str("[cell][left]", _color_link(link), "[/left][/cell]\n") + return str('[table=2]', text, '[/table]') + + +func _make_text(): + var gut_link_table = _link_table(_gut_links) + var vscode_link_table = _link_table(_vscode_links) + + var text = _bbcode.format({ + "gut_link_table":gut_link_table, + "vscode_link_table":vscode_link_table, + "donate_link":_color_link(str('[url]', _donate_link, '[/url]')), + "gut_version":GutUtils.version_numbers.gut_version, + }) + return text + + +func _vert_center_logo(): + _logo.position.y = size.y / 2.0 + + +# ----------- +# Events +# ----------- +func _on_rich_text_label_meta_clicked(meta: Variant) -> void: + OS.shell_open(str(meta)) + + +func _on_mouse_entered() -> void: + pass#_logo.active = true + + +func _on_mouse_exited() -> void: + pass#_logo.active = false + + +var _odd_ball_eyes_l = 1.1 +var _odd_ball_eyes_r = .7 +func _on_rich_text_label_meta_hover_started(meta: Variant) -> void: + if(meta == _gut_links[0][1]): + _logo.set_eye_color(Color.RED) + elif(meta.find("releases/tag/") > 0): + _logo.set_eye_color(Color.GREEN) + elif(meta == _gut_links[2][1]): + _logo.set_eye_color(Color.PURPLE) + elif(meta == _gut_links[3][1]): + _logo.set_eye_scale(1.2) + elif(meta == _vscode_links[0][1]): + _logo.set_eye_scale(.5, .5) + elif(meta == _vscode_links[1][1]): + _logo.set_eye_scale(_odd_ball_eyes_l, _odd_ball_eyes_r) + var temp = _odd_ball_eyes_l + _odd_ball_eyes_l = _odd_ball_eyes_r + _odd_ball_eyes_r = temp + elif(meta == _donate_link): + _logo.active = false + + +func _on_rich_text_label_meta_hover_ended(meta: Variant) -> void: + if(meta == _donate_link): + _logo.active = true + + +func _on_logo_pressed() -> void: + _logo.disabled = !_logo.disabled diff --git a/addons/gut/gui/about.gd.uid b/addons/gut/gui/about.gd.uid new file mode 100644 index 0000000..beeb0f8 --- /dev/null +++ b/addons/gut/gui/about.gd.uid @@ -0,0 +1 @@ +uid://g7qu8ihdt3pd diff --git a/addons/gut/gui/about.tscn b/addons/gut/gui/about.tscn new file mode 100644 index 0000000..d13904f --- /dev/null +++ b/addons/gut/gui/about.tscn @@ -0,0 +1,56 @@ +[gd_scene load_steps=5 format=3 uid="uid://dqbkylpsatcqm"] + +[ext_resource type="Script" uid="uid://g7qu8ihdt3pd" path="res://addons/gut/gui/about.gd" id="1_bg86c"] +[ext_resource type="PackedScene" uid="uid://bjkn8mhx2fmt1" path="res://addons/gut/gui/GutLogo.tscn" id="3_kpic4"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q8rky"] +bg_color = Color(0, 0, 0, 0.49803922) + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_kpic4"] + +[node name="About" type="AcceptDialog"] +oversampling_override = 1.0 +title = "About GUT" +position = Vector2i(0, 36) +size = Vector2i(1500, 800) +visible = true +min_size = Vector2i(800, 800) +script = ExtResource("1_bg86c") + +[node name="HBox" type="HBoxContainer" parent="."] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 1492.0 +offset_bottom = 751.0 +alignment = 1 + +[node name="MakeRoomForLogo" type="CenterContainer" parent="HBox"] +custom_minimum_size = Vector2(200, 0) +layout_mode = 2 + +[node name="Scroll" type="ScrollContainer" parent="HBox"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="RichTextLabel" type="RichTextLabel" parent="HBox/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_styles/normal = SubResource("StyleBoxFlat_q8rky") +theme_override_styles/focus = SubResource("StyleBoxEmpty_kpic4") +bbcode_enabled = true +fit_content = true + +[node name="Logo" parent="." instance=ExtResource("3_kpic4")] +modulate = Color(0.74509805, 0.74509805, 0.74509805, 1) +position = Vector2(151, 265) +scale = Vector2(0.8, 0.8) +active = true +disabled = true + +[connection signal="mouse_entered" from="." to="." method="_on_mouse_entered"] +[connection signal="mouse_exited" from="." to="." method="_on_mouse_exited"] +[connection signal="meta_clicked" from="HBox/Scroll/RichTextLabel" to="." method="_on_rich_text_label_meta_clicked"] +[connection signal="meta_hover_ended" from="HBox/Scroll/RichTextLabel" to="." method="_on_rich_text_label_meta_hover_ended"] +[connection signal="meta_hover_started" from="HBox/Scroll/RichTextLabel" to="." method="_on_rich_text_label_meta_hover_started"] +[connection signal="pressed" from="Logo" to="." method="_on_logo_pressed"] diff --git a/addons/gut/gui/arrow.png b/addons/gut/gui/arrow.png new file mode 100644 index 0000000..d407714 Binary files /dev/null and b/addons/gut/gui/arrow.png differ diff --git a/addons/gut/gui/arrow.png.import b/addons/gut/gui/arrow.png.import new file mode 100644 index 0000000..9c5e98c --- /dev/null +++ b/addons/gut/gui/arrow.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://6wra5rxmfsrl" +path="res://.godot/imported/arrow.png-2b5b2d838b5b3467cf300ac2da1630d9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/gui/arrow.png" +dest_files=["res://.godot/imported/arrow.png-2b5b2d838b5b3467cf300ac2da1630d9.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/gut/gui/editor_globals.gd b/addons/gut/gui/editor_globals.gd new file mode 100644 index 0000000..97d33bb --- /dev/null +++ b/addons/gut/gui/editor_globals.gd @@ -0,0 +1,68 @@ +@tool + +static var GutUserPreferences = load("res://addons/gut/gui/gut_user_preferences.gd") +static var temp_directory = 'user://gut_temp_directory' + +static var editor_run_gut_config_path = 'gut_editor_config.json': + # This avoids having to use path_join wherever we want to reference this + # path. The value is not supposed to change. Could it be a constant + # instead? Probably, but I didn't like repeating the directory part. + # Do I like that this is a bit witty. Absolutely. + get: return temp_directory.path_join(editor_run_gut_config_path) + # Should this print a message or something instead? Probably, but then I'd + # be repeating even more code than if this was just a constant. So I didn't, + # even though I wanted to make the message a easter eggish fun message. + # I didn't, so this dumb comment will have to serve as the easter eggish fun. + set(v): + print("Be sure to document your code. Never trust comments.") + + +static var editor_run_bbcode_results_path = 'gut_editor.bbcode': + get: return temp_directory.path_join(editor_run_bbcode_results_path) + set(v): pass + + +static var editor_run_json_results_path = 'gut_editor.json': + get: return temp_directory.path_join(editor_run_json_results_path) + set(v): pass + + +static var editor_shortcuts_path = 'gut_editor_shortcuts.cfg' : + get: return temp_directory.path_join(editor_shortcuts_path) + set(v): pass + +static var run_externally_options_path = 'gut_editor_run_externally.cfg' : + get: return temp_directory.path_join(run_externally_options_path) + set(v): pass + +static var _user_prefs = null +static var user_prefs = _user_prefs : + # workaround not being able to reference EditorInterface when not in + # the editor. This shouldn't be referenced by anything not in the + # editor. + get: + if(_user_prefs == null and Engine.is_editor_hint()): + # This is sometimes used when not in the editor. Avoid parser error + # for EditorInterface. + _user_prefs = GutUserPreferences.new(GutUtils.get_editor_interface().get_editor_settings()) + return _user_prefs +static var gut_plugin = null + +static func create_temp_directory(): + DirAccess.make_dir_recursive_absolute(temp_directory) + + +static func is_being_edited_in_editor(which): + if(!Engine.is_editor_hint()): + return false + + var trav = which + var is_scene_root = false + var editor_root = which.get_tree().edited_scene_root + while(trav != null and !is_scene_root): + is_scene_root = editor_root == trav + if(!is_scene_root): + trav = trav.get_parent() + return is_scene_root + + diff --git a/addons/gut/gui/editor_globals.gd.uid b/addons/gut/gui/editor_globals.gd.uid new file mode 100644 index 0000000..c3ae213 --- /dev/null +++ b/addons/gut/gui/editor_globals.gd.uid @@ -0,0 +1 @@ +uid://cbi00ubn046c2 diff --git a/addons/gut/gui/gut_config_gui.gd b/addons/gut/gui/gut_config_gui.gd new file mode 100644 index 0000000..e7b943c --- /dev/null +++ b/addons/gut/gui/gut_config_gui.gd @@ -0,0 +1,282 @@ +var PanelControls = load("res://addons/gut/gui/panel_controls.gd") +var GutConfig = load('res://addons/gut/gut_config.gd') + +const DIRS_TO_LIST = 6 + +# specific titles that we need to do stuff with +var _titles = { + dirs = null +} + +var _cfg_ctrls = {} +var opt_maker = null + +func _init(cont): + opt_maker = GutUtils.OptionMaker.new(cont) + _cfg_ctrls = opt_maker.controls + # _base_container = cont + + +func _add_save_load(): + var ctrl = PanelControls.SaveLoadControl.new('Config', '', '') + + ctrl.save_path_chosen.connect(_on_save_path_chosen) + ctrl.load_path_chosen.connect(_on_load_path_chosen) + + #_cfg_ctrls['save_load'] = ctrl + opt_maker.add_ctrl('save_load', ctrl) + return ctrl + +# ------------------ +# Events +# ------------------ +func _on_save_path_chosen(path): + save_file(path) + + +func _on_load_path_chosen(path): + load_file.bind(path).call_deferred() + +# ------------------ +# Public +# ------------------ +func get_config_issues(): + var to_return = [] + var has_directory = false + + for i in range(DIRS_TO_LIST): + var key = str('directory_', i) + var path = _cfg_ctrls[key].value + if(path != null and path != ''): + has_directory = true + if(!DirAccess.dir_exists_absolute(path)): + _cfg_ctrls[key].mark_invalid(true) + to_return.append(str('Test directory ', path, ' does not exist.')) + else: + _cfg_ctrls[key].mark_invalid(false) + else: + _cfg_ctrls[key].mark_invalid(false) + + if(!has_directory): + to_return.append('You do not have any directories set.') + _titles.dirs.mark_invalid(true) + else: + _titles.dirs.mark_invalid(false) + + if(!_cfg_ctrls.suffix.value.ends_with('.gd')): + _cfg_ctrls.suffix.mark_invalid(true) + to_return.append("Script suffix must end in '.gd'") + else: + _cfg_ctrls.suffix.mark_invalid(false) + + return to_return + + +func clear(): + opt_maker.clear() + + +func save_file(path): + var gcfg = GutConfig.new() + gcfg.options = get_options({}) + gcfg.save_file(path) + + + +func load_file(path): + var gcfg = GutConfig.new() + gcfg.load_options(path) + clear() + set_options(gcfg.options) + + +# -------------- +# SUPER dumb but VERY fun hack to hide settings. The various _add methods will +# return what they add. If you want to hide it, just assign the result to this. +# YES, I could have just put .visible at the end, but I didn't think of that +# until just now, and this was fun, non-permanent and the .visible at the end +# isn't as obvious as hide_this = +# +# Also, we can't just skip adding the controls because other things are looking +# for them and things start to blow up if you don't add them. +var hide_this = null : + set(val): + val.visible = false + +# -------------- + +func set_options(opts): + var options = opts.duplicate() + + # _add_title('Save/Load') + _add_save_load() + + opt_maker.add_title("Settings") + opt_maker.add_number("log_level", options.log_level, "Log Level", 0, 3, + "Detail level for log messages.\n" + \ + "\t0: Errors and failures only.\n" + \ + "\t1: Adds all test names + warnings + info\n" + \ + "\t2: Shows all asserts\n" + \ + "\t3: Adds more stuff probably, maybe not.") + opt_maker.add_float("wait_log_delay", options.wait_log_delay, "Wait Log Delay", 0.1, 0.0, 999.1, + "How long to wait before displaying 'Awaiting' messages.") + opt_maker.add_boolean('ignore_pause', options.ignore_pause, 'Ignore Pause', + "Ignore calls to pause_before_teardown") + opt_maker.add_boolean('hide_orphans', options.hide_orphans, 'Hide Orphans', + 'Do not display orphan counts in output.') + opt_maker.add_boolean('should_exit', options.should_exit, 'Exit on Finish', + "Exit when tests finished.") + opt_maker.add_boolean('should_exit_on_success', options.should_exit_on_success, 'Exit on Success', + "Exit if there are no failures. Does nothing if 'Exit on Finish' is enabled.") + opt_maker.add_select('double_strategy', 'Script Only', ['Include Native', 'Script Only'], 'Double Strategy', + '"Include Native" will include native methods in Doubles. "Script Only" will not. ' + "\n" + \ + 'The native method override warning is disabled when creating Doubles.' + "\n" + \ + 'This is the default, you can override this at the script level or when creating doubles.') + _cfg_ctrls.double_strategy.value = GutUtils.get_enum_value( + options.double_strategy, GutUtils.DOUBLE_STRATEGY, GutUtils.DOUBLE_STRATEGY.SCRIPT_ONLY) + + + opt_maker.add_title("Fail Error Types") + opt_maker.add_boolean("error_tracking", !options.no_error_tracking, 'Track Errors', + "Enable/Disable GUT's ability to detect engine and push errors.") + opt_maker.add_boolean('engine_errors_cause_failure', options.failure_error_types.has(GutConfig.FAIL_ERROR_TYPE_ENGINE), + 'Engine', 'Any script/engine error that occurs during a test will cause the test to fail.') + opt_maker.add_boolean('push_error_errors_cause_failure', options.failure_error_types.has(GutConfig.FAIL_ERROR_TYPE_PUSH_ERROR), + 'Push', 'Any error generated by a call to push_error that occurs during a test will cause the test to fail.') + opt_maker.add_boolean('gut_errors_cause_failure', options.failure_error_types.has(GutConfig.FAIL_ERROR_TYPE_GUT), + 'GUT', 'Any internal GUT error that occurs while a test is running will cause it to fail..') + + + opt_maker.add_title('Runner Appearance') + hide_this = opt_maker.add_boolean("gut_on_top", options.gut_on_top, "On Top", + "The GUT Runner appears above children added during tests.") + opt_maker.add_number('opacity', options.opacity, 'Opacity', 0, 100, + "The opacity of GUT when tests are running.") + hide_this = opt_maker.add_boolean('should_maximize', options.should_maximize, 'Maximize', + "Maximize GUT when tests are being run.") + opt_maker.add_boolean('compact_mode', options.compact_mode, 'Compact Mode', + 'The runner will be in compact mode. This overrides Maximize.') + opt_maker.add_select('font_name', options.font_name, GutUtils.avail_fonts, 'Font', + "The font to use for text output in the Gut Runner.") + opt_maker.add_number('font_size', options.font_size, 'Font Size', 5, 100, + "The font size for text output in the Gut Runner.") + hide_this = opt_maker.add_color('font_color', options.font_color, 'Font Color', + "The font color for text output in the Gut Runner.") + opt_maker.add_color('background_color', options.background_color, 'Background Color', + "The background color for text output in the Gut Runner.") + opt_maker.add_boolean('disable_colors', options.disable_colors, 'Disable Formatting', + 'Disable formatting and colors used in the Runner. Does not affect panel output.') + + + _titles.dirs = opt_maker.add_title('Test Directories') + opt_maker.add_boolean('include_subdirs', options.include_subdirs, 'Include Subdirs', + "Include subdirectories of the directories configured below.") + + var dirs_to_load = options.configured_dirs + if(options.dirs.size() > dirs_to_load.size()): + dirs_to_load = options.dirs + + for i in range(DIRS_TO_LIST): + var value = '' + if(dirs_to_load.size() > i): + value = dirs_to_load[i] + + var test_dir = opt_maker.add_directory(str('directory_', i), value, str(i)) + test_dir.enabled_button.visible = true + test_dir.enabled_button.button_pressed = options.dirs.has(value) + + + opt_maker.add_title("XML Output") + opt_maker.add_save_file_anywhere("junit_xml_file", options.junit_xml_file, "Output Path", + "Path and filename where GUT should create a JUnit compliant XML file. " + + "This file will contain the results of the last test run. To avoid " + + "overriding the file use Include Timestamp.") + opt_maker.add_boolean("junit_xml_timestamp", options.junit_xml_timestamp, "Include Timestamp", + "Include a timestamp in the filename so that each run gets its own xml file.") + + + opt_maker.add_title('Hooks') + opt_maker.add_file('pre_run_script', options.pre_run_script, 'Pre-Run Hook', + 'This script will be run by GUT before any tests are run.') + opt_maker.add_file('post_run_script', options.post_run_script, 'Post-Run Hook', + 'This script will be run by GUT after all tests are run.') + + + opt_maker.add_title('Misc') + opt_maker.add_value('prefix', options.prefix, 'Script Prefix', + "The filename prefix for all test scripts.") + opt_maker.add_value('suffix', options.suffix, 'Script Suffix', + "Script suffix, including .gd extension. For example '_foo.gd'.") + opt_maker.add_float('paint_after', options.paint_after, 'Paint After', .05, 0.0, 1.0, + "How long GUT will wait before pausing for 1 frame to paint the screen. 0 is never.") + + + +func get_options(base_opts): + var to_return = base_opts.duplicate() + + # Settings + to_return.log_level = _cfg_ctrls.log_level.value + to_return.wait_log_delay = _cfg_ctrls.wait_log_delay.value + to_return.ignore_pause = _cfg_ctrls.ignore_pause.value + to_return.hide_orphans = _cfg_ctrls.hide_orphans.value + to_return.should_exit = _cfg_ctrls.should_exit.value + to_return.should_exit_on_success = _cfg_ctrls.should_exit_on_success.value + to_return.double_strategy = _cfg_ctrls.double_strategy.value + + # Runner Appearance + to_return.font_name = _cfg_ctrls.font_name.text + to_return.font_size = _cfg_ctrls.font_size.value + to_return.should_maximize = _cfg_ctrls.should_maximize.value + to_return.compact_mode = _cfg_ctrls.compact_mode.value + to_return.opacity = _cfg_ctrls.opacity.value + to_return.background_color = _cfg_ctrls.background_color.value.to_html() + to_return.font_color = _cfg_ctrls.font_color.value.to_html() + to_return.disable_colors = _cfg_ctrls.disable_colors.value + to_return.gut_on_top = _cfg_ctrls.gut_on_top.value + to_return.paint_after = _cfg_ctrls.paint_after.value + + # Fail Error Types + to_return.no_error_tracking = !_cfg_ctrls.error_tracking + + var fail_error_types = [] + if(_cfg_ctrls.engine_errors_cause_failure.value): + fail_error_types.append(GutConfig.FAIL_ERROR_TYPE_ENGINE) + if(_cfg_ctrls.push_error_errors_cause_failure.value): + fail_error_types.append(GutConfig.FAIL_ERROR_TYPE_PUSH_ERROR) + if(_cfg_ctrls.gut_errors_cause_failure.value): + fail_error_types.append(GutConfig.FAIL_ERROR_TYPE_GUT) + to_return.failure_error_types = fail_error_types + + # Directories + to_return.include_subdirs = _cfg_ctrls.include_subdirs.value + var dirs = [] + var configured_dirs = [] + for i in range(DIRS_TO_LIST): + var key = str('directory_', i) + var ctrl = _cfg_ctrls[key] + if(ctrl.value != '' and ctrl.value != null): + configured_dirs.append(ctrl.value) + if(ctrl.enabled_button.button_pressed): + dirs.append(ctrl.value) + to_return.dirs = dirs + to_return.configured_dirs = configured_dirs + + # XML Output + to_return.junit_xml_file = _cfg_ctrls.junit_xml_file.value + to_return.junit_xml_timestamp = _cfg_ctrls.junit_xml_timestamp.value + + # Hooks + to_return.pre_run_script = _cfg_ctrls.pre_run_script.value + to_return.post_run_script = _cfg_ctrls.post_run_script.value + + # Misc + to_return.prefix = _cfg_ctrls.prefix.value + to_return.suffix = _cfg_ctrls.suffix.value + + return to_return + + +func mark_saved(): + for key in _cfg_ctrls: + _cfg_ctrls[key].mark_unsaved(false) diff --git a/addons/gut/gui/gut_config_gui.gd.uid b/addons/gut/gui/gut_config_gui.gd.uid new file mode 100644 index 0000000..3aa4cd4 --- /dev/null +++ b/addons/gut/gui/gut_config_gui.gd.uid @@ -0,0 +1 @@ +uid://chosc1tvfaduq diff --git a/addons/gut/gui/gut_gui.gd b/addons/gut/gui/gut_gui.gd new file mode 100644 index 0000000..618e199 --- /dev/null +++ b/addons/gut/gui/gut_gui.gd @@ -0,0 +1,239 @@ +extends Control +# ############################################################################## +# This is the decoupled GUI for gut.gd +# +# This is a "generic" interface between a GUI and gut.gd. It assumes there are +# certain controls with specific names. It will then interact with those +# controls based on signals emitted from gut.gd in order to give the user +# feedback about the progress of the test run and the results. +# +# Optional controls are marked as such in the _ctrls dictionary. The names +# of the controls can be found in _populate_ctrls. +# ############################################################################## +var _gut = null + +var _ctrls = { + btn_continue = null, + path_dir = null, + path_file = null, + prog_script = null, + prog_test = null, + rtl = null, # optional + rtl_bg = null, # required if rtl exists + switch_modes = null, + time_label = null, + title = null, + title_bar = null, + tgl_word_wrap = null, # optional +} + +var _title_mouse = { + down = false +} + + +signal switch_modes() + +var _max_position = Vector2(100, 100) + +func _ready(): + _populate_ctrls() + + _ctrls.btn_continue.visible = false + _ctrls.btn_continue.pressed.connect(_on_continue_pressed) + _ctrls.switch_modes.pressed.connect(_on_switch_modes_pressed) + _ctrls.title_bar.gui_input.connect(_on_title_bar_input) + if(_ctrls.tgl_word_wrap != null): + _ctrls.tgl_word_wrap.toggled.connect(_on_word_wrap_toggled) + + _ctrls.prog_script.value = 0 + _ctrls.prog_test.value = 0 + _ctrls.path_dir.text = '' + _ctrls.path_file.text = '' + _ctrls.time_label.text = '' + + _max_position = get_display_size() - Vector2(30, _ctrls.title_bar.size.y) + + +func _process(_delta): + if(_gut != null and _gut.is_running()): + set_elapsed_time(_gut.get_elapsed_time()) + + +# ------------------ +# Private +# ------------------ +func get_display_size(): + return get_viewport().get_visible_rect().size + + +func _populate_ctrls(): + # Brute force, but flexible. This allows for all the controls to exist + # anywhere, and as long as they all have the right name, they will be + # found. + _ctrls.btn_continue = _get_first_child_named('Continue', self) + _ctrls.path_dir = _get_first_child_named('Path', self) + _ctrls.path_file = _get_first_child_named('File', self) + _ctrls.prog_script = _get_first_child_named('ProgressScript', self) + _ctrls.prog_test = _get_first_child_named('ProgressTest', self) + _ctrls.rtl = _get_first_child_named('TestOutput', self) + _ctrls.rtl_bg = _get_first_child_named('OutputBG', self) + _ctrls.switch_modes = _get_first_child_named("SwitchModes", self) + _ctrls.time_label = _get_first_child_named('TimeLabel', self) + _ctrls.title = _get_first_child_named("Title", self) + _ctrls.title_bar = _get_first_child_named("TitleBar", self) + _ctrls.tgl_word_wrap = _get_first_child_named("WordWrap", self) + + +func _get_first_child_named(obj_name, parent_obj): + if(parent_obj == null): + return null + + var kids = parent_obj.get_children() + var index = 0 + var to_return = null + + while(index < kids.size() and to_return == null): + if(str(kids[index]).find(str(obj_name, ':')) != -1): + to_return = kids[index] + else: + to_return = _get_first_child_named(obj_name, kids[index]) + if(to_return == null): + index += 1 + + return to_return + + + +# ------------------ +# Events +# ------------------ +func _on_title_bar_input(event : InputEvent): + if(event is InputEventMouseMotion): + if(_title_mouse.down): + position += event.relative + position.x = clamp(position.x, 0, _max_position.x) + position.y = clamp(position.y, 0, _max_position.y) + elif(event is InputEventMouseButton): + if(event.button_index == MOUSE_BUTTON_LEFT): + _title_mouse.down = event.pressed + + +func _on_continue_pressed(): + _gut.end_teardown_pause() + + +func _on_gut_start_run(): + if(_ctrls.rtl != null): + _ctrls.rtl.clear() + set_num_scripts(_gut.get_test_collector().scripts.size()) + + +func _on_gut_end_run(): + _ctrls.prog_test.value = _ctrls.prog_test.max_value + _ctrls.prog_script.value = _ctrls.prog_script.max_value + + +func _on_gut_start_script(script_obj): + next_script(script_obj.get_full_name(), script_obj.tests.size()) + + +func _on_gut_end_script(): + pass + + +func _on_gut_start_test(test_name): + next_test(test_name) + + +func _on_gut_end_test(): + pass + + +func _on_gut_start_pause(): + pause_before_teardown() + + +func _on_gut_end_pause(): + _ctrls.btn_continue.visible = false + + +func _on_switch_modes_pressed(): + switch_modes.emit() + + +func _on_word_wrap_toggled(toggled): + _ctrls.rtl.autowrap_mode = toggled +# ------------------ +# Public +# ------------------ +func set_num_scripts(val): + _ctrls.prog_script.value = 0 + _ctrls.prog_script.max_value = val + + +func next_script(path, num_tests): + _ctrls.prog_script.value += 1 + _ctrls.prog_test.value = 0 + _ctrls.prog_test.max_value = num_tests + + _ctrls.path_dir.text = path.get_base_dir() + _ctrls.path_file.text = path.get_file() + + +func next_test(__test_name): + _ctrls.prog_test.value += 1 + + +func pause_before_teardown(): + _ctrls.btn_continue.visible = true + + +func set_gut(g): + if(_gut == g): + return + _gut = g + g.start_run.connect(_on_gut_start_run) + g.end_run.connect(_on_gut_end_run) + + g.start_script.connect(_on_gut_start_script) + g.end_script.connect(_on_gut_end_script) + + g.start_test.connect(_on_gut_start_test) + g.end_test.connect(_on_gut_end_test) + + g.start_pause_before_teardown.connect(_on_gut_start_pause) + g.end_pause_before_teardown.connect(_on_gut_end_pause) + +func get_gut(): + return _gut + +func get_textbox(): + return _ctrls.rtl + +func set_elapsed_time(t): + _ctrls.time_label.text = str("%6.1f" % t, 's') + + +func set_bg_color(c): + _ctrls.rtl_bg.color = c + + +func set_title(text): + _ctrls.title.text = text + + +func to_top_left(): + self.position = Vector2(5, 5) + + +func to_bottom_right(): + var win_size = get_display_size() + self.position = win_size - Vector2(self.size) - Vector2(5, 5) + + +func align_right(): + var win_size = get_display_size() + self.position.x = win_size.x - self.size.x -5 + self.position.y = 5 + self.size.y = win_size.y - 10 diff --git a/addons/gut/gui/gut_gui.gd.uid b/addons/gut/gui/gut_gui.gd.uid new file mode 100644 index 0000000..329177a --- /dev/null +++ b/addons/gut/gui/gut_gui.gd.uid @@ -0,0 +1 @@ +uid://blvhsbnsvfyow diff --git a/addons/gut/gui/gut_logo.gd b/addons/gut/gui/gut_logo.gd new file mode 100644 index 0000000..d576a71 --- /dev/null +++ b/addons/gut/gui/gut_logo.gd @@ -0,0 +1,225 @@ +@tool +extends Node2D + +class Eyeball: + extends Node2D + + var _should_draw_laser = false + var _laser_end_pos = Vector2.ZERO + var _laser_timer : Timer = null + var _color_tween : Tween + var _size_tween : Tween + + var sprite : Sprite2D = null + var default_position = Vector2(0, 0) + var move_radius = 25 + var move_center = Vector2(0, 0) + var default_color = Color(0.31, 0.31, 0.31) + var _color = default_color : + set(val): + _color = val + queue_redraw() + var color = _color : + set(val): + _start_color_tween(_color, val) + get(): return _color + var default_size = 70 + var _size = default_size : + set(val): + _size = val + queue_redraw() + var size = _size : + set(val): + _start_size_tween(_size, val) + get(): return _size + + + func _init(node): + sprite = node + default_position = sprite.position + move_center = sprite.position + # hijack the original sprite, because I want to draw it here but keep + # the original in the scene for layout. + position = sprite.position + sprite.get_parent().add_child(self) + sprite.visible = false + + + func _ready(): + _laser_timer = Timer.new() + _laser_timer.wait_time = .1 + _laser_timer.one_shot = true + add_child(_laser_timer) + _laser_timer.timeout.connect(func(): _should_draw_laser = false) + + + func _process(_delta): + if(_should_draw_laser): + queue_redraw() + + + func _start_color_tween(old_color, new_color): + if(_color_tween != null and _color_tween.is_running()): + _color_tween.kill() + _color_tween = create_tween() + _color_tween.tween_property(self, '_color', new_color, .3).from(old_color) + _color_tween.play() + + + func _start_size_tween(old_size, new_size): + if(_size_tween != null and _size_tween.is_running()): + _size_tween.kill() + _size_tween = create_tween() + _size_tween.tween_property(self, '_size', new_size, .3).from(old_size) + _size_tween.play() + + + var _laser_size = 20.0 + func _draw() -> void: + draw_circle(Vector2.ZERO, size, color, true, -1, true) + if(_should_draw_laser): + var end_pos = (_laser_end_pos - global_position) * 2 + var laser_size = _laser_size * (float(size)/float(default_size)) + draw_line(Vector2.ZERO, end_pos, color, laser_size) + draw_line(Vector2.ZERO, end_pos, Color(1, 1, 1, .5), laser_size * .8) + + + # There's a bug in here where the eye shakes like crazy. It's a feature + # now. Don't fix it. + func look_at_local_position(local_pos): + var dir = position.direction_to(local_pos) + var dist = position.distance_to(local_pos) + position = move_center + (dir * min(dist, move_radius)) + position.x = clamp(position.x, move_center.x - move_radius, move_center.x + move_radius) + position.y = clamp(position.y, move_center.y - move_radius, move_center.y + move_radius) + + + func reset(): + color = default_color + size = default_size + + + func eye_laser(global_pos): + _should_draw_laser = true + _laser_end_pos = global_pos + _laser_timer.start() + + + func _stop_laser(): + _should_draw_laser = false + + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +var GutEditorGlobals = load('res://addons/gut/gui/editor_globals.gd') +# Active means it's actively doing stuff. When this is not active the eyes +# won't follow, but you can still make the sizes change by calling methods on +# this. +@export var active = false : + set(val): + active = val + if(!active and is_inside_tree()): + left_eye.position = left_eye.default_position + right_eye.position = right_eye.default_position +# When disabled, this will reset to default and you can't make it do anything. +@export var disabled = false : + set(val): + disabled = val + if(disabled and is_inside_tree()): + left_eye.position = left_eye.default_position + right_eye.position = right_eye.default_position + left_eye.reset() + right_eye.reset() + modulate = Color.GRAY + $BaseLogo.texture = _no_shine + else: + $BaseLogo.texture = _normal + modulate = Color.WHITE + +@onready var _reset_timer = $ResetTimer +@onready var _face_button = $FaceButton +@onready var left_eye : Eyeball = Eyeball.new($BaseLogo/LeftEye) +@onready var right_eye : Eyeball = Eyeball.new($BaseLogo/RightEye) + +var _no_shine = load("res://addons/gut/images/GutIconV2_no_shine.png") +var _normal = load("res://addons/gut/images/GutIconV2_base.png") +var _is_in_edited_scene = false + +signal pressed + +func _debug_ready(): + position = Vector2(500, 500) + active = true + + +func _ready(): + _is_in_edited_scene = GutEditorGlobals.is_being_edited_in_editor(self) + + if(get_parent() == get_tree().root): + _debug_ready() + + disabled = disabled + active = active + left_eye.move_center.x -= 20 + right_eye.move_center.x += 10 + _face_button.modulate.a = 0.0 + + +func _process(_delta): + if(active and !disabled and !_is_in_edited_scene): + left_eye.look_at_local_position(get_local_mouse_position()) + right_eye.look_at_local_position(get_local_mouse_position()) + + +# ---------------- +# Events +# ---------------- +func _on_reset_timer_timeout() -> void: + left_eye.reset() + right_eye.reset() + + +func _on_face_button_pressed() -> void: + pressed.emit() + + +# ---------------- +# Public +# ---------------- +func set_eye_scale(left, right=left): + if(disabled or _is_in_edited_scene): + return + left_eye.size = left_eye.default_size * left + right_eye.size = right_eye.default_size * right + _reset_timer.start() + + +func reset_eye_size(): + if(disabled or _is_in_edited_scene): + return + left_eye.size = left_eye.default_size + right_eye.size = right_eye.default_size + + +func set_eye_color(left, right=left): + if(disabled or _is_in_edited_scene): + return + left_eye.color = left + right_eye.color = right + _reset_timer.start() + + +func reset_eye_color(): + if(disabled or _is_in_edited_scene): + return + left_eye.color = left_eye.default_color + right_eye.color = right_eye.default_color + + +# I removed the eye lasers because they aren't ready yet. I've already spent +# too much time on this logo. It's great, I love it...but it's been long +# enough. This gives me, or someone else, something to do later. +#func eye_lasers(global_pos): + #left_eye.eye_laser(global_pos) + #right_eye.eye_laser(global_pos) diff --git a/addons/gut/gui/gut_logo.gd.uid b/addons/gut/gui/gut_logo.gd.uid new file mode 100644 index 0000000..359575d --- /dev/null +++ b/addons/gut/gui/gut_logo.gd.uid @@ -0,0 +1 @@ +uid://b8lvgepb64m8t diff --git a/addons/gut/gui/gut_user_preferences.gd b/addons/gut/gui/gut_user_preferences.gd new file mode 100644 index 0000000..1e22c02 --- /dev/null +++ b/addons/gut/gui/gut_user_preferences.gd @@ -0,0 +1,79 @@ +class GutEditorPref: + var gut_pref_prefix = 'gut/' + var pname = '__not_set__' + var default = null + var value = '__not_set__' + var _settings = null + + func _init(n, d, s): + pname = n + default = d + _settings = s + load_it() + + func _prefstr(): + var to_return = str(gut_pref_prefix, pname) + return to_return + + func save_it(): + _settings.set_setting(_prefstr(), value) + + func load_it(): + if(_settings.has_setting(_prefstr())): + value = _settings.get_setting(_prefstr()) + else: + value = default + + func erase(): + _settings.erase(_prefstr()) + + +const EMPTY = '-- NOT_SET --' + +# -- Editor ONLY Settings -- +var output_font_name = null +var output_font_size = null +var hide_result_tree = null +var hide_output_text = null +var hide_settings = null +var use_colors = null # ? might be output panel +var run_externally = null +var run_externally_options_dialog_size = null +var shortcuts_dialog_size = null +var gut_window_size = null +var gut_window_on_top = null + + +func _init(editor_settings): + output_font_name = GutEditorPref.new('output_font_name', 'CourierPrime', editor_settings) + output_font_size = GutEditorPref.new('output_font_size', 30, editor_settings) + hide_result_tree = GutEditorPref.new('hide_result_tree', false, editor_settings) + hide_output_text = GutEditorPref.new('hide_output_text', false, editor_settings) + hide_settings = GutEditorPref.new('hide_settings', false, editor_settings) + use_colors = GutEditorPref.new('use_colors', true, editor_settings) + run_externally = GutEditorPref.new('run_externally', false, editor_settings) + run_externally_options_dialog_size = GutEditorPref.new('run_externally_options_dialog_size', Vector2i(-1, -1), editor_settings) + shortcuts_dialog_size = GutEditorPref.new('shortcuts_dialog_size', Vector2i(-1, -1), editor_settings) + gut_window_size = GutEditorPref.new('editor_window_size', Vector2i(-1, -1), editor_settings) + gut_window_on_top = GutEditorPref.new('editor_window_on_top', false, editor_settings) + + +func save_it(): + for prop in get_property_list(): + var val = get(prop.name) + if(val is GutEditorPref): + val.save_it() + + +func load_it(): + for prop in get_property_list(): + var val = get(prop.name) + if(val is GutEditorPref): + val.load_it() + + +func erase_all(): + for prop in get_property_list(): + var val = get(prop.name) + if(val is GutEditorPref): + val.erase() diff --git a/addons/gut/gui/gut_user_preferences.gd.uid b/addons/gut/gui/gut_user_preferences.gd.uid new file mode 100644 index 0000000..d4f3d4d --- /dev/null +++ b/addons/gut/gui/gut_user_preferences.gd.uid @@ -0,0 +1 @@ +uid://dsndkn6whyiov diff --git a/addons/gut/gui/option_maker.gd b/addons/gut/gui/option_maker.gd new file mode 100644 index 0000000..43f31a5 --- /dev/null +++ b/addons/gut/gui/option_maker.gd @@ -0,0 +1,124 @@ +var PanelControls = load("res://addons/gut/gui/panel_controls.gd") + +# All titles so we can free them when we want. +var _all_titles = [] + + +var base_container = null +# All the various PanelControls indexed by thier keys. +var controls = {} + + +func _init(cont): + base_container = cont + + +func add_title(text): + var row = PanelControls.BaseGutPanelControl.new(text, text) + base_container.add_child(row) + row.connect('draw', _on_title_cell_draw.bind(row)) + _all_titles.append(row) + return row + + +func add_ctrl(key, ctrl): + controls[key] = ctrl + base_container.add_child(ctrl) + + +func add_number(key, value, disp_text, v_min, v_max, hint=''): + var ctrl = PanelControls.NumberControl.new(disp_text, value, v_min, v_max, hint) + add_ctrl(key, ctrl) + return ctrl + + +func add_float(key, value, disp_text, step, v_min, v_max, hint=''): + var ctrl = PanelControls.FloatControl.new(disp_text, value, step, v_min, v_max, hint) + add_ctrl(key, ctrl) + return ctrl + + +func add_select(key, value, values, disp_text, hint=''): + var ctrl = PanelControls.SelectControl.new(disp_text, value, values, hint) + add_ctrl(key, ctrl) + return ctrl + + +func add_value(key, value, disp_text, hint=''): + var ctrl = PanelControls.StringControl.new(disp_text, value, hint) + add_ctrl(key, ctrl) + return ctrl + +func add_multiline_text(key, value, disp_text, hint=''): + var ctrl = PanelControls.MultiLineStringControl.new(disp_text, value, hint) + add_ctrl(key, ctrl) + return ctrl + +func add_boolean(key, value, disp_text, hint=''): + var ctrl = PanelControls.BooleanControl.new(disp_text, value, hint) + add_ctrl(key, ctrl) + return ctrl + + +func add_directory(key, value, disp_text, hint=''): + var ctrl = PanelControls.DirectoryControl.new(disp_text, value, hint) + add_ctrl(key, ctrl) + ctrl.dialog.title = disp_text + return ctrl + + +func add_file(key, value, disp_text, hint=''): + var ctrl = PanelControls.DirectoryControl.new(disp_text, value, hint) + add_ctrl(key, ctrl) + ctrl.dialog.file_mode = ctrl.dialog.FILE_MODE_OPEN_FILE + ctrl.dialog.title = disp_text + return ctrl + + +func add_save_file_anywhere(key, value, disp_text, hint=''): + var ctrl = PanelControls.DirectoryControl.new(disp_text, value, hint) + add_ctrl(key, ctrl) + ctrl.dialog.file_mode = ctrl.dialog.FILE_MODE_SAVE_FILE + ctrl.dialog.access = ctrl.dialog.ACCESS_FILESYSTEM + ctrl.dialog.title = disp_text + return ctrl + + +func add_color(key, value, disp_text, hint=''): + var ctrl = PanelControls.ColorControl.new(disp_text, value, hint) + add_ctrl(key, ctrl) + return ctrl + + +var _blurbs = 0 +func add_blurb(text): + var ctrl = RichTextLabel.new() + ctrl.fit_content = true + ctrl.bbcode_enabled = true + ctrl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + ctrl.text = text + add_ctrl(str("blurb_", _blurbs), ctrl) + return ctrl + + +# ------------------ +# Events +# ------------------ +func _on_title_cell_draw(which): + which.draw_rect(Rect2(Vector2(0, 0), which.size), Color(0, 0, 0, .15)) + + +# ------------------ +# Public +# ------------------ + +func clear(): + for key in controls: + controls[key].free() + + controls.clear() + + for entry in _all_titles: + entry.free() + + _all_titles.clear() diff --git a/addons/gut/gui/option_maker.gd.uid b/addons/gut/gui/option_maker.gd.uid new file mode 100644 index 0000000..13a94b4 --- /dev/null +++ b/addons/gut/gui/option_maker.gd.uid @@ -0,0 +1 @@ +uid://bjahqsqo645sf diff --git a/addons/gut/gui/panel_controls.gd b/addons/gut/gui/panel_controls.gd new file mode 100644 index 0000000..a3d5ffe --- /dev/null +++ b/addons/gut/gui/panel_controls.gd @@ -0,0 +1,484 @@ +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +class BaseGutPanelControl: + extends HBoxContainer + var label = Label.new() + var _lbl_unsaved = Label.new() + var _lbl_invalid = Label.new() + + var value = null: + get: return get_value() + set(val): set_value(val) + + signal changed + + func _init(title, val, hint=""): + size_flags_horizontal = SIZE_EXPAND_FILL + mouse_filter = MOUSE_FILTER_PASS + + label.size_flags_horizontal = label.SIZE_EXPAND_FILL + label.mouse_filter = label.MOUSE_FILTER_STOP + add_child(label) + + _lbl_unsaved.text = '*' + _lbl_unsaved.visible = false + add_child(_lbl_unsaved) + + _lbl_invalid.text = '!' + _lbl_invalid.visible = false + add_child(_lbl_invalid) + + label.text = title + label.tooltip_text = hint + + + func mark_unsaved(is_it=true): + _lbl_unsaved.visible = is_it + + + func mark_invalid(is_it): + _lbl_invalid.visible = is_it + + # -- Virtual -- + # + # value_ctrl (all should declare the value_ctrl) + # + func set_value(value): + pass + + func get_value(): + pass + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +class NumberControl: + extends BaseGutPanelControl + + var value_ctrl = SpinBox.new() + + func _init(title, val, v_min, v_max, hint=""): + super._init(title, val, hint) + + value_ctrl.value = val + value_ctrl.size_flags_horizontal = value_ctrl.SIZE_EXPAND_FILL + value_ctrl.min_value = v_min + value_ctrl.max_value = v_max + value_ctrl.value_changed.connect(_on_value_changed) + value_ctrl.select_all_on_focus = true + add_child(value_ctrl) + + func _on_value_changed(new_value): + changed.emit() + + func get_value(): + return value_ctrl.value + + func set_value(val): + value_ctrl.value = val + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +class FloatControl: + extends NumberControl + + func _init(title, val, step, v_min, v_max, hint=""): + super._init(title, val, v_min, v_max, hint) + value_ctrl.step = step + value_ctrl.value = val + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +class StringControl: + extends BaseGutPanelControl + + var value_ctrl = LineEdit.new() + + func _init(title, val, hint=""): + super._init(title, val, hint) + + value_ctrl.size_flags_horizontal = value_ctrl.SIZE_EXPAND_FILL + value_ctrl.text = val + value_ctrl.text_changed.connect(_on_text_changed) + value_ctrl.select_all_on_focus = true + add_child(value_ctrl) + if(title == ''): + label.visible = false + + func _on_text_changed(new_value): + changed.emit() + + func get_value(): + return value_ctrl.text + + func set_value(val): + value_ctrl.text = val + + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +class MultiLineStringControl: + extends BaseGutPanelControl + + var value_ctrl = TextEdit.new() + + func _init(title, val, hint=""): + super._init(title, val, hint) + var vbox = VBoxContainer.new() + vbox.size_flags_horizontal = SIZE_EXPAND_FILL + add_child(vbox) + label.reparent(vbox) + value_ctrl.size_flags_horizontal = value_ctrl.SIZE_EXPAND_FILL + value_ctrl.text = val + value_ctrl.text_changed.connect(_on_text_changed) + value_ctrl.scroll_fit_content_height = true + vbox.add_child(value_ctrl) + + func _on_text_changed(new_value): + changed.emit() + + func get_value(): + return value_ctrl.text + + func set_value(val): + value_ctrl.text = val + + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +class BooleanControl: + extends BaseGutPanelControl + + var value_ctrl = CheckBox.new() + + func _init(title, val, hint=""): + super._init(title, val, hint) + + value_ctrl.button_pressed = val + value_ctrl.toggled.connect(_on_button_toggled) + add_child(value_ctrl) + + func _on_button_toggled(new_value): + changed.emit() + + func get_value(): + return value_ctrl.button_pressed + + func set_value(val): + value_ctrl.button_pressed = val + + +# ------------------------------------------------------------------------------ +# value is "selected" and is gettable and settable +# text is the text value of the selected item, it is gettable only +# ------------------------------------------------------------------------------ +class SelectControl: + extends BaseGutPanelControl + + var value_ctrl = OptionButton.new() + + var text = '' : + get: return value_ctrl.get_item_text(value_ctrl.selected) + set(val): pass + + func _init(title, val, choices, hint=""): + super._init(title, val, hint) + + var select_idx = 0 + for i in range(choices.size()): + value_ctrl.add_item(choices[i]) + if(val == choices[i]): + select_idx = i + value_ctrl.selected = select_idx + value_ctrl.size_flags_horizontal = value_ctrl.SIZE_EXPAND_FILL + value_ctrl.item_selected.connect(_on_item_selected) + add_child(value_ctrl) + + func _on_item_selected(idx): + changed.emit() + + func get_value(): + return value_ctrl.selected + + func set_value(val): + value_ctrl.selected = val + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +class ColorControl: + extends BaseGutPanelControl + + var value_ctrl = ColorPickerButton.new() + + func _init(title, val, hint=""): + super._init(title, val, hint) + value_ctrl.size_flags_horizontal = value_ctrl.SIZE_EXPAND_FILL + value_ctrl.color = val + add_child(value_ctrl) + + func get_value(): + return value_ctrl.color + + func set_value(val): + value_ctrl.color = val + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +class DirectoryControl: + extends BaseGutPanelControl + + var value_ctrl := LineEdit.new() + var dialog := FileDialog.new() + var enabled_button = CheckButton.new() + + var _btn_dir := Button.new() + + func _init(title, val, hint=""): + super._init(title, val, hint) + + label.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + + _btn_dir.text = '...' + _btn_dir.pressed.connect(_on_dir_button_pressed) + + value_ctrl.text = val + value_ctrl.size_flags_horizontal = value_ctrl.SIZE_EXPAND_FILL + value_ctrl.select_all_on_focus = true + value_ctrl.text_changed.connect(_on_value_changed) + + dialog.file_mode = dialog.FILE_MODE_OPEN_DIR + dialog.unresizable = false + dialog.dir_selected.connect(_on_selected) + dialog.file_selected.connect(_on_selected) + + enabled_button.button_pressed = true + enabled_button.visible = false + + add_child(enabled_button) + add_child(value_ctrl) + add_child(_btn_dir) + add_child(dialog) + + func _update_display(): + var is_empty = value_ctrl.text == '' + enabled_button.button_pressed = !is_empty + enabled_button.disabled = is_empty + + + func _ready(): + if(Engine.is_editor_hint()): + dialog.size = Vector2(1000, 700) + else: + dialog.size = Vector2(500, 350) + _update_display() + + func _on_value_changed(new_text): + _update_display() + + func _on_selected(path): + value_ctrl.text = path + _update_display() + + func _on_dir_button_pressed(): + dialog.current_dir = value_ctrl.text + dialog.popup_centered() + + func get_value(): + return value_ctrl.text + + func set_value(val): + value_ctrl.text = val + + +# ------------------------------------------------------------------------------ +# Features: +# Buttons to pick res://, user://, or anywhere on the OS. +# ------------------------------------------------------------------------------ +class FileDialogSuperPlus: + extends FileDialog + + var show_diretory_types = true : + set(val) : + show_diretory_types = val + _update_display() + + var show_res = true : + set(val) : + show_res = val + _update_display() + + var show_user = true : + set(val) : + show_user = val + _update_display() + + var show_os = true : + set(val) : + show_os = val + _update_display() + + var _dir_type_hbox = null + var _btn_res = null + var _btn_user = null + var _btn_os = null + + func _ready(): + _init_controls() + _update_display() + + + func _init_controls(): + _dir_type_hbox = HBoxContainer.new() + + _btn_res = Button.new() + _btn_user = Button.new() + _btn_os = Button.new() + var spacer1 = CenterContainer.new() + spacer1.size_flags_horizontal = spacer1.SIZE_EXPAND_FILL + var spacer2 = spacer1.duplicate() + + _dir_type_hbox.add_child(spacer1) + _dir_type_hbox.add_child(_btn_res) + _dir_type_hbox.add_child(_btn_user) + _dir_type_hbox.add_child(_btn_os) + _dir_type_hbox.add_child(spacer2) + + _btn_res.text = 'res://' + _btn_user.text = 'user://' + _btn_os.text = ' OS ' + + get_vbox().add_child(_dir_type_hbox) + get_vbox().move_child(_dir_type_hbox, 0) + + _btn_res.pressed.connect(func(): access = ACCESS_RESOURCES) + _btn_user.pressed.connect(func(): access = ACCESS_USERDATA) + _btn_os.pressed.connect(func(): access = ACCESS_FILESYSTEM) + + + func _update_display(): + if(is_inside_tree()): + _dir_type_hbox.visible = show_diretory_types + _btn_res.visible = show_res + _btn_user.visible = show_user + _btn_os.visible = show_os + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +class SaveLoadControl: + extends BaseGutPanelControl + + var btn_load = Button.new() + var btn_save = Button.new() + + var dlg_load := FileDialogSuperPlus.new() + var dlg_save := FileDialogSuperPlus.new() + + signal save_path_chosen(path) + signal load_path_chosen(path) + + func _init(title, val, hint): + super._init(title, val, hint) + + btn_load.text = "Load" + btn_load.custom_minimum_size.x = 100 + btn_load.pressed.connect(_on_load_pressed) + add_child(btn_load) + + btn_save.text = "Save As" + btn_save.custom_minimum_size.x = 100 + btn_save.pressed.connect(_on_save_pressed) + add_child(btn_save) + + dlg_load.file_mode = dlg_load.FILE_MODE_OPEN_FILE + dlg_load.unresizable = false + dlg_load.dir_selected.connect(_on_load_selected) + dlg_load.file_selected.connect(_on_load_selected) + add_child(dlg_load) + + dlg_save.file_mode = dlg_save.FILE_MODE_SAVE_FILE + dlg_save.unresizable = false + dlg_save.dir_selected.connect(_on_save_selected) + dlg_save.file_selected.connect(_on_save_selected) + add_child(dlg_save) + + + func _ready(): + if(Engine.is_editor_hint()): + dlg_load.size = Vector2(1000, 700) + dlg_save.size = Vector2(1000, 700) + else: + dlg_load.size = Vector2(500, 350) + dlg_save.size = Vector2(500, 350) + + func _on_load_selected(path): + load_path_chosen.emit(path) + + func _on_save_selected(path): + save_path_chosen.emit(path) + + func _on_load_pressed(): + dlg_load.popup_centered() + + func _on_save_pressed(): + dlg_save.popup_centered() + +# ------------------------------------------------------------------------------ +# This one was never used in gut_config_gui...but I put some work into it and +# I'm a sucker for that kinda thing. Delete this when you get tired of looking +# at it. +# ------------------------------------------------------------------------------ +# class Vector2Ctrl: +# extends VBoxContainer + +# var value = Vector2(-1, -1) : +# get: +# return get_value() +# set(val): +# set_value(val) +# var disabled = false : +# get: +# return get_disabled() +# set(val): +# set_disabled(val) +# var x_spin = SpinBox.new() +# var y_spin = SpinBox.new() + +# func _init(): +# add_child(_make_one('x: ', x_spin)) +# add_child(_make_one('y: ', y_spin)) + +# func _make_one(txt, spinner): +# var hbox = HBoxContainer.new() +# var lbl = Label.new() +# lbl.text = txt +# hbox.add_child(lbl) +# hbox.add_child(spinner) +# spinner.min_value = -1 +# spinner.max_value = 10000 +# spinner.size_flags_horizontal = spinner.SIZE_EXPAND_FILL +# return hbox + +# func set_value(v): +# if(v != null): +# x_spin.value = v[0] +# y_spin.value = v[1] + +# # Returns array instead of vector2 b/c that is what is stored in +# # in the dictionary and what is expected everywhere else. +# func get_value(): +# return [x_spin.value, y_spin.value] + +# func set_disabled(should): +# get_parent().visible = !should +# x_spin.visible = !should +# y_spin.visible = !should + +# func get_disabled(): +# pass diff --git a/addons/gut/gui/panel_controls.gd.uid b/addons/gut/gui/panel_controls.gd.uid new file mode 100644 index 0000000..3b6d8f4 --- /dev/null +++ b/addons/gut/gui/panel_controls.gd.uid @@ -0,0 +1 @@ +uid://db54jy04d8w7p diff --git a/addons/gut/gui/play.png b/addons/gut/gui/play.png new file mode 100644 index 0000000..06fbff3 Binary files /dev/null and b/addons/gut/gui/play.png differ diff --git a/addons/gut/gui/play.png.import b/addons/gut/gui/play.png.import new file mode 100644 index 0000000..e1a8f0a --- /dev/null +++ b/addons/gut/gui/play.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cr6tvdv0ve6cv" +path="res://.godot/imported/play.png-5c90e88e8136487a183a099d67a7de24.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/gui/play.png" +dest_files=["res://.godot/imported/play.png-5c90e88e8136487a183a099d67a7de24.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/gut/gui/run_from_editor.gd b/addons/gut/gui/run_from_editor.gd new file mode 100644 index 0000000..67c5f88 --- /dev/null +++ b/addons/gut/gui/run_from_editor.gd @@ -0,0 +1,19 @@ +# ------------------------------------------------------------------------------ +# This is the entry point when running tests from the editor. +# +# This script should conform to, or ignore, the strictest warning settings. +# ------------------------------------------------------------------------------ +extends Node2D + +var GutLoader : Object + +func _init() -> void: + GutLoader = load("res://addons/gut/gut_loader.gd") + + +@warning_ignore("unsafe_method_access") +func _ready() -> void: + var runner : Node = load("res://addons/gut/gui/GutRunner.tscn").instantiate() + add_child(runner) + runner.run_from_editor() + GutLoader.restore_ignore_addons() diff --git a/addons/gut/gui/run_from_editor.gd.uid b/addons/gut/gui/run_from_editor.gd.uid new file mode 100644 index 0000000..b782883 --- /dev/null +++ b/addons/gut/gui/run_from_editor.gd.uid @@ -0,0 +1 @@ +uid://bwf2iuidqfkpl diff --git a/addons/gut/gui/run_from_editor.tscn b/addons/gut/gui/run_from_editor.tscn new file mode 100644 index 0000000..a979090 --- /dev/null +++ b/addons/gut/gui/run_from_editor.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bgj3fm5d8yvjw"] + +[ext_resource type="Script" uid="uid://bwf2iuidqfkpl" path="res://addons/gut/gui/run_from_editor.gd" id="1_53pap"] + +[node name="RunFromEditor" type="Node2D"] +script = ExtResource("1_53pap") diff --git a/addons/gut/gut.gd b/addons/gut/gut.gd new file mode 100644 index 0000000..27ffdd7 --- /dev/null +++ b/addons/gut/gut.gd @@ -0,0 +1,1258 @@ +extends 'res://addons/gut/gut_to_move.gd' +class_name GutMain +## The GUT brains. +## +## Most of this class is for internal use only. Features that can be used are +## have descriptions and can be accessed through the [member GutTest.gut] variable +## in your test scripts (extends [GutTest]). +## The wiki page for this class contains only the usable features. +## [br][br] +## GUT Wiki: [url=https://gut.readthedocs.io]https://gut.readthedocs.io[/url] +## [br] +## @ignore-uncommented + + +# --------------------------- +# Constants +# --------------------------- +const LOG_LEVEL_FAIL_ONLY = 0 +const LOG_LEVEL_TEST_AND_FAILURES = 1 +const LOG_LEVEL_ALL_ASSERTS = 2 +const WAITING_MESSAGE = '/# waiting #/' +const PAUSE_MESSAGE = '/# Pausing. Press continue button...#/' +const COMPLETED = 'completed' + +# --------------------------- +# Signals +# --------------------------- +signal start_pause_before_teardown +signal end_pause_before_teardown + +signal start_run +signal end_run +signal start_script(test_script_obj) +signal end_script +signal start_test(test_name) +signal end_test + + +# --------------------------- +# Settings +# +# These are properties that are usually set before a run is started through +# gutconfig. +# --------------------------- + +var _inner_class_name = '' +# When set, GUT will only run Inner-Test-Classes that contain this string. +var inner_class_name = _inner_class_name : + get: return _inner_class_name + set(val): _inner_class_name = val + +var _ignore_pause_before_teardown = false +# For batch processing purposes, you may want to ignore any calls to +# pause_before_teardown that you forgot to remove_at. +var ignore_pause_before_teardown = _ignore_pause_before_teardown : + get: return _ignore_pause_before_teardown + set(val): _ignore_pause_before_teardown = val + +var _log_level = 1 +## The log detail level. Valid values are 0 - 2. Larger values do not matter. +var log_level = _log_level: + get: return _log_level + set(val): _set_log_level(val) + +## The amount of time that must elapse before an "Awaiting" message is printed. +var wait_log_delay = 0.5 + +# TODO 4.0 +# This appears to not be used anymore. Going to wait for more tests to be +# ported before removing. +var _disable_strict_datatype_checks = false +var disable_strict_datatype_checks = false : + get: return _disable_strict_datatype_checks + set(val): _disable_strict_datatype_checks = val + +var _export_path = '' +# Path to file that GUT will create which holds a list of all test scripts so +# that GUT can run tests when a project is exported. +var export_path = '' : + get: return _export_path + set(val): _export_path = val + +var _include_subdirectories = false +# Setting this to true will make GUT search all subdirectories of any directory +# you have configured GUT to search for tests in. +var include_subdirectories = _include_subdirectories : + get: return _include_subdirectories + set(val): _include_subdirectories = val + + +var _double_strategy = GutUtils.DOUBLE_STRATEGY.SCRIPT_ONLY +# TODO rework what this is and then document it here. +var double_strategy = _double_strategy : + get: return _double_strategy + set(val): + if(GutUtils.DOUBLE_STRATEGY.values().has(val)): + _double_strategy = val + _doubler.set_strategy(double_strategy) + else: + _lgr.error(str("gut.gd: invalid double_strategy ", val)) + +var _pre_run_script = '' +# Path to the script that will be run before all tests are run. This script +# must extend GutHookScript +var pre_run_script = _pre_run_script : + get: return _pre_run_script + set(val): _pre_run_script = val + +var _post_run_script = '' +# Path to the script that will run after all tests have run. The script +# must extend GutHookScript +var post_run_script = _post_run_script : + get: return _post_run_script + set(val): _post_run_script = val + +var _color_output = false +# Flag to color output at the command line and in the GUT GUI. +var color_output = false : + get: return _color_output + set(val): + _color_output = val + _lgr.disable_formatting(!_color_output) + +var _junit_xml_file = '' +# The full path to where GUT should write a JUnit compliant XML file to which +# contains the results of all tests run. +var junit_xml_file = '' : + get: return _junit_xml_file + set(val): _junit_xml_file = val + +var _junit_xml_timestamp = false +# When true and junit_xml_file is set, the file name will include a +# timestamp so that previous files are not overwritten. +var junit_xml_timestamp = false : + get: return _junit_xml_timestamp + set(val): _junit_xml_timestamp = val + +# The minimum amout of time GUT will wait before pausing for 1 frame to allow +# the screen to paint. GUT checkes after each test to see if enough time has +# passed. +var paint_after = .1: + get: return paint_after + set(val): paint_after = val + +var _unit_test_name = '' +# When set GUT will only run tests that contain this string. +var unit_test_name = _unit_test_name : + get: return _unit_test_name + set(val): _unit_test_name = val + +var _parameter_handler = null +# This is populated by test.gd each time a paramterized test is encountered +# for the first time. +# FOR INTERNAL USE ONLY +var parameter_handler = _parameter_handler : + get: return _parameter_handler + set(val): + _parameter_handler = val + _parameter_handler.set_logger(_lgr) + +var _lgr = GutUtils.get_logger() +# Local reference for the common logger. +var logger = _lgr : + get: return _lgr + set(val): + _lgr = val + _lgr.set_gut(self) + +var error_tracker = GutUtils.get_error_tracker() + +var _add_children_to = self +# Sets the object that GUT will add test objects to as it creates them. The +# default is self, but can be set to other objects so that GUT is not obscured +# by the objects added during tests. +var add_children_to = self : + get: return _add_children_to + set(val): _add_children_to = val + + +# ------------ +# Read only +# ------------ +var _test_collector = GutUtils.TestCollector.new() +func get_test_collector(): + return _test_collector + +# var version = null : +func get_version(): + return GutUtils.version_numbers.gut_version + +var _orphan_counter = GutUtils.OrphanCounter.new() +func get_orphan_counter(): + return _orphan_counter + +# var _autofree = GutUtils.AutoFree.new() +func get_autofree(): + return _orphan_counter.autofree + +var _stubber = GutUtils.Stubber.new() +func get_stubber(): + return _stubber + +var _doubler = GutUtils.Doubler.new() +func get_doubler(): + return _doubler + +var _spy = GutUtils.Spy.new() +func get_spy(): + return _spy + +var _is_running = false +func is_running(): + return _is_running + + +# --------------------------- +# Private +# --------------------------- +var _should_print_versions = true # used to cut down on output in tests. +var _should_print_summary = true + +var _file_prefix = 'test_' +var _inner_class_prefix = 'Test' + +var _select_script = '' +var _last_paint_time = 0.0 +var _strutils = GutUtils.Strutils.new() + +# The instance that is created from _pre_run_script. Accessible from +# get_pre_run_script_instance. These are created at the start of the run +# and then referenced at the appropriate time. This allows us to validate the +# scripts prior to running. +var _pre_run_script_instance = null +var _post_run_script_instance = null + +var _script_name = null + +# The instanced scripts. This is populated as the scripts are run. +var _test_script_objects = [] + +var _waiting = false + +# msecs ticks when run was started +var _start_time = 0.0 + +# Collected Test instance for the current test being run. +var _current_test = null +var _pause_before_teardown = false + + +# Used to cancel importing scripts if an error has occurred in the setup. This +# prevents tests from being run if they were exported and ensures that the +# error displayed is seen since importing generates a lot of text. +# +# TODO this appears to only be checked and never set anywhere. Verify that this +# was not broken somewhere and remove if no longer used. +var _cancel_import = false + +# this is how long Gut will wait when there are items that must be queued free +# when a test completes (due to calls to add_child_autoqfree) +var _auto_queue_free_delay = .1 + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _init(override_logger=null): + if(override_logger != null): + logger = override_logger + else: + logger = logger # force setter logic + + _doubler.set_stubber(_stubber) + _doubler.set_spy(_spy) + _doubler.set_gut(self) + + update_loggers() + +# Public for tests that set the logger. This makes it much easier to propigate +# test loggers. +func update_loggers(): + _doubler.set_logger(_lgr) + _spy.set_logger(_lgr) + _stubber.set_logger(_lgr) + _test_collector.set_logger(_lgr) + + +# ------------------------------------------------------------------------------ +# Initialize controls +# ------------------------------------------------------------------------------ +func _ready(): + if(_should_print_versions): + _lgr.log('--- GUT ---') + _lgr.info(str('using [', OS.get_user_data_dir(), '] for temporary output.')) + + if(_select_script != null): + select_script(_select_script) + + _print_versions() + +# ------------------------------------------------------------------------------ +# Runs right before free is called. Can't override `free`. +# ------------------------------------------------------------------------------ +func _notification(what): + if(what == NOTIFICATION_PREDELETE): + for ts in _test_script_objects: + if(is_instance_valid(ts)): + ts.free() + + _test_script_objects = [] + + +func _print_versions(send_all = true): + if(!_should_print_versions): + return + + var info = GutUtils.version_numbers.get_version_text() + + if(send_all): + p(info) + else: + _lgr.get_printer('gui').send(info + "\n") + + + + +# --------------------------- +# +# Accessor code +# +# --------------------------- + + +# ------------------------------------------------------------------------------ +# Set the log level. Use one of the various LOG_LEVEL_* constants. +# ------------------------------------------------------------------------------ +func _set_log_level(level): + _log_level = max(level, 0) + + # Level 0 settings + _lgr.set_less_test_names(level == 0) + # Explicitly always enabled + _lgr.set_type_enabled(_lgr.types.normal, true) + _lgr.set_type_enabled(_lgr.types.error, true) + _lgr.set_type_enabled(_lgr.types.pending, true) + + # Level 1 types + _lgr.set_type_enabled(_lgr.types.warn, level > 0) + _lgr.set_type_enabled(_lgr.types.deprecated, level > 0) + + # Level 2 types + _lgr.set_type_enabled(_lgr.types.passed, level > 1) + _lgr.set_type_enabled(_lgr.types.info, level > 1) + _lgr.set_type_enabled(_lgr.types.debug, level > 1) + +# --------------------------- +# +# Events +# +# --------------------------- +func end_teardown_pause(): + _pause_before_teardown = false + _waiting = false + end_pause_before_teardown.emit() + +# --------------------------- +# +# Private +# +# --------------------------- +func _log_test_children_warning(test_script): + if(!_lgr.is_type_enabled(_lgr.types.orphan)): + return + + var kids = test_script.get_children() + if(kids.size() > 1): + var msg = '' + if(_log_level == 2): + msg = "Test script still has children when all tests finisehd.\n" + for i in range(kids.size()): + msg += str(" ", _strutils.type2str(kids[i]), "\n") + msg += "You can use autofree, autoqfree, add_child_autofree, or add_child_autoqfree to automatically free objects." + else: + msg = str("Test script has ", kids.size(), " unfreed children. Increase log level for more details.") + + _lgr.warn(msg) + + +func _log_end_run(): + var summary = GutUtils.Summary.new(self) + if(_should_print_summary): + _orphan_counter.record_orphans("end_run") + if(_lgr.is_type_enabled("orphan") and _orphan_counter.get_count() > 0): + _lgr.log("\n\n\n") + _lgr.orphan("==============================================") + _lgr.orphan(str('= ', _orphan_counter.get_count(), ' Orphans')) + _lgr.orphan("==============================================") + _orphan_counter.log_all() + _lgr.log("\n") + else: + _lgr.log("\n\n\n") + + summary.log_end_run() + + +func _validate_hook_script(path): + var result = { + valid = true, + instance = null + } + + # empty path is valid but will have a null instance + if(path == ''): + return result + + if(FileAccess.file_exists(path)): + var inst = load(path).new() + if(inst and inst is GutHookScript): + result.instance = inst + result.valid = true + else: + result.valid = false + _lgr.error('The hook script [' + path + '] does not extend GutHookScript') + else: + result.valid = false + _lgr.error('The hook script [' + path + '] does not exist.') + + return result + + +# ------------------------------------------------------------------------------ +# Runs a hook script. Script must exist, and must extend +# GutHookScript or addons/gut/hook_script.gd +# ------------------------------------------------------------------------------ +func _run_hook_script(inst): + if(inst != null): + inst.gut = self + await inst.run() + return inst + + +# ------------------------------------------------------------------------------ +# Initialize variables for each run of a single test script. +# ------------------------------------------------------------------------------ +func _init_run(): + var valid = true + _test_collector.set_test_class_prefix(_inner_class_prefix) + _test_script_objects = [] + _current_test = null + _is_running = true + + var pre_hook_result = _validate_hook_script(_pre_run_script) + _pre_run_script_instance = pre_hook_result.instance + var post_hook_result = _validate_hook_script(_post_run_script) + _post_run_script_instance = post_hook_result.instance + + valid = pre_hook_result.valid and post_hook_result.valid + + return valid + + +# ------------------------------------------------------------------------------ +# Print out run information and close out the run. +# ------------------------------------------------------------------------------ +func _end_run(): + await _run_hook_script(get_post_run_script_instance()) + + _orphan_counter.record_orphans("end_run") + _orphan_counter.orphanage.clean() + _log_end_run() + _is_running = false + + _export_results() + end_run.emit() + + +# ------------------------------------------------------------------------------ +# Add additional export types here. +# ------------------------------------------------------------------------------ +func _export_results(): + if(_junit_xml_file != ''): + _export_junit_xml() + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _export_junit_xml(): + var exporter = GutUtils.JunitXmlExport.new() + var output_file = _junit_xml_file + + if(_junit_xml_timestamp): + var ext = "." + output_file.get_extension() + output_file = output_file.replace(ext, str("_", Time.get_unix_time_from_system(), ext)) + + var f_result = exporter.write_file(self, output_file) + if(f_result == OK): + p(str("Results saved to ", output_file)) + + +# ------------------------------------------------------------------------------ +# Print out the heading for a new script +# ------------------------------------------------------------------------------ +func _print_script_heading(coll_script): + if(_does_class_name_match(_inner_class_name, coll_script.inner_class_name)): + _lgr.log(str("\n\n", coll_script.get_full_name()), _lgr.fmts.underline) + + +# ------------------------------------------------------------------------------ +# Yes if the class name is null or the script's class name includes class_name +# ------------------------------------------------------------------------------ +func _does_class_name_match(the_class_name, script_class_name): + return (the_class_name == null or the_class_name == '') or \ + (script_class_name != null and str(script_class_name).findn(the_class_name) != -1) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _create_script_instance(collected_script): + var test_script = collected_script.get_new() + + test_script.gut = self + test_script.set_logger(_lgr) + _add_children_to.add_child(test_script) + _test_script_objects.append(test_script) + test_script.wait_log_delay = wait_log_delay + + if(!test_script._was_ready_called): + test_script._do_ready_stuff() + _lgr.warn(str("!!! YOU HAVE UPSET YOUR GUT !!!\n", + "You have overridden _ready in [", collected_script.get_filename_and_inner(), "] ", + "but it does not call super._ready(). New additions (or maybe old ", + "by the time you see this) require that super._ready() is called.", + "\n\n", + "GUT is working around this infraction, but may not be able to in ", + "the future. GUT also reserves the right to decide it does not want ", + "to work around it in the future. ", + "You should probably use before_all instead of _ready. I can think ", + "of a few reasons why you would want to use _ready but I won't list ", + "them here because I think they are bad ideas. I know they are bad ", + "ideas because I did them. Hence the warning. This message is ", + "intentially long so that it bothers you and you change your ways.\n\n", + "Thank you for using GUT.")) + return test_script + + +# ------------------------------------------------------------------------------ +# returns self so it can be integrated into the yield call. +# ------------------------------------------------------------------------------ +func _wait_for_continue_button(): + p(PAUSE_MESSAGE, 0) + _waiting = true + return self + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _get_indexes_matching_script_name(script_name): + var indexes = [] # empty runs all + for i in range(_test_collector.scripts.size()): + if(_test_collector.scripts[i].get_filename().find(script_name) != -1): + indexes.append(i) + return indexes + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _get_indexes_matching_path(path): + var indexes = [] + for i in range(_test_collector.scripts.size()): + if(_test_collector.scripts[i].path == path): + indexes.append(i) + return indexes + + +# ------------------------------------------------------------------------------ +# Execute all calls of a parameterized test. +# ------------------------------------------------------------------------------ +func _run_parameterized_test(test_script, test_name): + await _run_test(test_script, test_name, 0) + + if(_current_test.assert_count == 0 and !_current_test.pending): + _lgr.risky('Test did not assert') + + if(_parameter_handler == null): + _lgr.error(str('Parameterized test ', _current_test.name, ' did not call use_parameters for the default value of the parameter.')) + _fail(str('Parameterized test ', _current_test.name, ' did not call use_parameters for the default value of the parameter.')) + else: + var index = 1 + while(!_parameter_handler.is_done()): + var cur_assert_count = _current_test.assert_count + await _run_test(test_script, test_name, index) + if(_current_test.assert_count == cur_assert_count and !_current_test.pending): + _lgr.risky('Test did not assert') + index += 1 + + _parameter_handler = null + + +# ------------------------------------------------------------------------------ +# Runs a single test given a test.gd instance and the name of the test to run. +# ------------------------------------------------------------------------------ +func _run_test(script_inst, test_name, param_index = -1): + _lgr.log_test_name() + _lgr.set_indent_level(1) + + await script_inst.before_each() + + start_test.emit(test_name) + var test_id = str(script_inst.collected_script.get_filename_and_inner(), ':', test_name) + if(param_index != -1): + test_id += str('[', param_index, ']') + error_tracker.start_test(test_id) + + await script_inst.call(test_name) + + if(error_tracker.should_test_fail_from_errors(test_id)): + script_inst._fail(str("Unexpected Errors:\n", error_tracker.get_fail_text_for_errors(test_id))) + + error_tracker.end_test() + # if the test called pause_before_teardown then await until + # the continue button is pressed. + if(_pause_before_teardown and !_ignore_pause_before_teardown): + start_pause_before_teardown.emit() + await _wait_for_continue_button().end_pause_before_teardown + + script_inst.clear_signal_watcher() + + await script_inst.after_each() + + # Free up everything in the _autofree. Yield for a bit if we + # have anything with a queue_free so that they have time to + # free and are not found by the orphan counter. + var aqf_count = _orphan_counter.autofree.get_queue_free_count() + _orphan_counter.autofree.free_all() + if(aqf_count > 0): + await get_tree().create_timer(_auto_queue_free_delay).timeout + + _orphan_counter.end_test( + script_inst.collected_script.get_filename_and_inner(), test_name, + _log_level > 0) + + _doubler.get_ignored_methods().clear() + + +func get_current_test_orphans(): + var sname = get_current_test_object().collected_script.get_ref().get_filename_and_inner() + var tname = get_current_test_object().name + _orphan_counter.record_orphans(sname, tname) + return _orphan_counter.get_orphan_ids(sname, tname) + + +# ------------------------------------------------------------------------------ +# Calls before_all on the passed in test script and takes care of settings so all +# logger output appears indented and with a proper heading +# +# Calls both pre-all-tests methods until prerun_setup is removed +# ------------------------------------------------------------------------------ +func _call_before_all(test_script, collected_script): + var before_all_test_obj = GutUtils.CollectedTest.new() + before_all_test_obj.has_printed_name = false + before_all_test_obj.name = 'before_all' + + collected_script.setup_teardown_tests.append(before_all_test_obj) + _current_test = before_all_test_obj + + _lgr.inc_indent() + await test_script.before_all() + # before all does not need to assert anything so only mark it as run if + # some assert was done. + before_all_test_obj.was_run = before_all_test_obj.did_something() + + _lgr.dec_indent() + + _current_test = null + + +# ------------------------------------------------------------------------------ +# Calls after_all on the passed in test script and takes care of settings so all +# logger output appears indented and with a proper heading +# +# Calls both post-all-tests methods until postrun_teardown is removed. +# ------------------------------------------------------------------------------ +func _call_after_all(test_script, collected_script): + var after_all_test_obj = GutUtils.CollectedTest.new() + after_all_test_obj.has_printed_name = false + after_all_test_obj.name = 'after_all' + + collected_script.setup_teardown_tests.append(after_all_test_obj) + _current_test = after_all_test_obj + + _lgr.inc_indent() + await test_script.after_all() + # after all does not need to assert anything so only mark it as run if + # some assert was done. + after_all_test_obj.was_run = after_all_test_obj.did_something() + _lgr.dec_indent() + + _current_test = null + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _should_skip_script(test_script, collected_script): + var skip_message = 'not skipped' + var skip_value = test_script.get('skip_script') + var should_skip = false + + if(skip_value == null): + skip_value = await test_script.should_skip_script() + else: + _lgr.deprecated('Using the skip_script var has been deprecated. Implement the new should_skip_script() method in your test instead.') + + if(skip_value != null): + if(typeof(skip_value) == TYPE_BOOL): + should_skip = skip_value + if(skip_value): + skip_message = 'script marked to skip' + elif(typeof(skip_value) == TYPE_STRING): + should_skip = true + skip_message = skip_value + + if(should_skip): + var msg = str('- [Script skipped]: ', skip_message) + _lgr.inc_indent() + _lgr.log(msg, _lgr.fmts.yellow) + _lgr.dec_indent() + collected_script.skip_reason = skip_message + collected_script.was_skipped = true + + return should_skip + + +# ------------------------------------------------------------------------------ +# Run all tests in a script. This is the core logic for running tests. +# ------------------------------------------------------------------------------ +func _test_the_scripts(indexes=[]): + + _print_versions(false) + var is_valid = _init_run() + if(!is_valid): + _lgr.error('Something went wrong and the run was aborted.') + return + + await _run_hook_script(get_pre_run_script_instance()) + if(_pre_run_script_instance!= null and _pre_run_script_instance.should_abort()): + _lgr.error('pre-run abort') + end_run.emit() + return + + start_run.emit() + _start_time = Time.get_ticks_msec() + _last_paint_time = _start_time + + var indexes_to_run = [] + if(indexes.size()==0): + for i in range(_test_collector.scripts.size()): + indexes_to_run.append(i) + else: + indexes_to_run = indexes + + + # loop through scripts + for test_indexes in range(indexes_to_run.size()): + var coll_script = _test_collector.scripts[indexes_to_run[test_indexes]] + + if(coll_script.tests.size() > 0): + _lgr.set_indent_level(0) + _print_script_heading(coll_script) + + if(!coll_script.is_loaded): + break + + start_script.emit(coll_script) + + var test_script = _create_script_instance(coll_script) + _doubler.set_strategy(_double_strategy) + + # ---- + # SHORTCIRCUIT + # skip_script logic + if(await _should_skip_script(test_script, coll_script)): + _orphan_counter.record_orphans(coll_script.get_full_name()) + continue + # ---- + + # !!! + # Hack so there isn't another indent to this monster of a method. if + # inner class is set and we do not have a match then empty the tests + # for the current test. + # !!! + if(!_does_class_name_match(_inner_class_name, coll_script.inner_class_name)): + coll_script.tests = [] + else: + coll_script.was_run = true + await _call_before_all(test_script, coll_script) + + _orphan_counter.record_orphans(coll_script.get_full_name()) + # Each test in the script + for i in range(coll_script.tests.size()): + _stubber.clear() + _spy.clear() + _current_test = coll_script.tests[i] + + if((_unit_test_name != '' and _current_test.name.find(_unit_test_name) > -1) or + (_unit_test_name == '')): + + var ticks_before := Time.get_ticks_usec() + + if(_current_test.arg_count > 1): + _lgr.error(str('Parameterized test ', _current_test.name, + ' has too many parameters: ', _current_test.arg_count, '.')) + elif(_current_test.arg_count == 1): + _current_test.was_run = true + await _run_parameterized_test(test_script, _current_test.name) + else: + _current_test.was_run = true + await _run_test(test_script, _current_test.name) + + if(!_current_test.did_something()): + _lgr.risky(str(_current_test.name, ' did not assert')) + + _current_test.has_printed_name = false + + _current_test.time_taken = (Time.get_ticks_usec() - ticks_before) / 1000000.0 + + end_test.emit() + + # After each test, check to see if we shoudl wait a frame to + # paint based on how much time has elapsed since we last 'painted' + if(paint_after > 0.0): + var now = Time.get_ticks_msec() + var time_since = (now - _last_paint_time) / 1000.0 + if(time_since > paint_after): + _last_paint_time = now + await get_tree().process_frame + + _current_test = null + _lgr.dec_indent() + + if(_does_class_name_match(_inner_class_name, coll_script.inner_class_name)): + await _call_after_all(test_script, coll_script) + + _orphan_counter.end_script( + coll_script.get_filename_and_inner(), + _log_level > 0) + + _log_test_children_warning(test_script) + # This might end up being very resource intensive if the scripts + # don't clean up after themselves. Might have to consolidate output + # into some other structure and kill the script objects with + # test_script.free() instead of remove_at child. + _add_children_to.remove_child(test_script) + + _lgr.set_indent_level(0) + if(test_script.get_assert_count() > 0): + var script_sum = str(coll_script.get_passing_test_count(), '/', coll_script.get_ran_test_count(), ' passed.') + _lgr.log(script_sum, _lgr.fmts.bold) + + test_script.queue_free() + end_script.emit() + # END TEST SCRIPT LOOP + + _lgr.set_indent_level(0) + # Give anything that is queued to be freed time to be freed before we count + # the orphans. Without this, the last test's awaiter won't be freed + # yet, which messes with the orphans total. There could also be objects + # the user has queued to be freed as well. + # Bump number from .1 to .5 when inner classes that were not run were still + # appearing as orphans. Maybe this could loop through the orpahns looking + # for entries that were not freed but are queued to be freed and wait unitl + # they are all gone. ".5" is a lot easier. + await get_tree().create_timer(.5).timeout + _end_run() + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _pass(text=''): + if(_current_test): + _current_test.add_pass(text) + + +# ------------------------------------------------------------------------------ +# Returns an empty string or "(call #x) " if the current test being run has +# parameters. The +# ------------------------------------------------------------------------------ +func get_call_count_text(): + var to_return = '' + if(_parameter_handler != null): + # This uses get_call_count -1 because test.gd's use_parameters method + # should have been called before we get to any calls for this method + # just due to how use_parameters works. There isn't a way to know + # whether we are before or after that call. + to_return = str('params[', _parameter_handler.get_call_count() -1, '] ') + return to_return + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _fail(text=''): + if(_current_test != null): + var line_number = _extract_line_number(_current_test) + var line_text = ' at line ' + str(line_number) + p(line_text, LOG_LEVEL_FAIL_ONLY) + # format for summary + line_text = "\n " + line_text + var call_count_text = get_call_count_text() + _current_test.line_number = line_number + _current_test.add_fail(call_count_text + text + line_text) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _pending(text=''): + if(_current_test): + _current_test.add_pending(text) + + +# ------------------------------------------------------------------------------ +# Extracts the line number from curren stacktrace by matching the test case name +# ------------------------------------------------------------------------------ +func _extract_line_number(current_test): + var line_number = -1 + # if stack trace available than extraxt the test case line number + var stackTrace = get_stack() + if(stackTrace!=null): + for index in stackTrace.size(): + var line = stackTrace[index] + var function = line.get("function") + if function == current_test.name: + line_number = line.get("line") + return line_number + + +# ------------------------------------------------------------------------------ +# Gets all the files in a directory and all subdirectories if include_subdirectories +# is true. The files returned are all sorted by name. +# ------------------------------------------------------------------------------ +func _get_files(path, prefix, suffix): + var files = [] + var directories = [] + # ignore addons/gut per issue 294 + if(path == 'res://addons/gut'): + return []; + + var d = DirAccess.open(path) + d.include_hidden = false + d.include_navigational = false + + # Traversing a directory is kinda odd. You have to start the process of + # listing the contents of a directory with list_dir_begin then use get_next + # until it returns an empty string. Then I guess you should end it. + d.list_dir_begin() + var fs_item = d.get_next() + var full_path = '' + while(fs_item != ''): + full_path = path.path_join(fs_item) + + # MUST use FileAccess since d.file_exists returns false for exported + # projects + if(FileAccess.file_exists(full_path)): + if(fs_item.begins_with(prefix) and fs_item.ends_with(suffix)): + files.append(full_path) + # MUST use DirAccess, d.dir_exists is false for exported projects. + elif(include_subdirectories and DirAccess.dir_exists_absolute(full_path)): + directories.append(full_path) + + fs_item = d.get_next() + d.list_dir_end() + + for dir in range(directories.size()): + var dir_files = _get_files(directories[dir], prefix, suffix) + for i in range(dir_files.size()): + files.append(dir_files[i]) + + files.sort() + return files + + +# --------------------------- +# +# public +# +# --------------------------- + +func get_elapsed_time() -> float: + var to_return = 0.0 + if(_start_time != 0.0): + to_return = Time.get_ticks_msec() - _start_time + to_return = to_return / 1000.0 + + return to_return + +# ------------------------------------------------------------------------------ +# Conditionally prints the text to the console/results variable based on the +# current log level and what level is passed in. Whenever currently in a test, +# the text will be indented under the test. It can be further indented if +# desired. +# +# The first time output is generated when in a test, the test name will be +# printed. +# ------------------------------------------------------------------------------ +func p(text, level=0): + var str_text = str(text) + + if(level <= GutUtils.nvl(_log_level, 0)): + _lgr.log(str_text) + +# --------------------------- +# +# RUN TESTS/ADD SCRIPTS +# +# --------------------------- + +# ------------------------------------------------------------------------------ +# Runs all the scripts that were added using add_script +# ------------------------------------------------------------------------------ +func test_scripts(_run_rest=false): + if(_script_name != null and _script_name != ''): + var indexes = _get_indexes_matching_script_name(_script_name) + if(indexes == []): + _lgr.error(str( + "Could not find script matching '", _script_name, "'.\n", + "Check your directory settings and Script Prefix/Suffix settings.")) + end_run.emit() + else: + _test_the_scripts(indexes) + else: + _test_the_scripts([]) + +# alias +func run_tests(run_rest=false): + test_scripts(run_rest) + + +# ------------------------------------------------------------------------------ +# Runs a single script passed in. +# ------------------------------------------------------------------------------ +# func run_test_script(script): +# _test_collector.set_test_class_prefix(_inner_class_prefix) +# _test_collector.clear() +# _test_collector.add_script(script) +# _test_the_scripts() + + +# ------------------------------------------------------------------------------ +# Adds a script to be run when test_scripts called. +# ------------------------------------------------------------------------------ +func add_script(script): + # if(!Engine.is_editor_hint()): + _test_collector.set_test_class_prefix(_inner_class_prefix) + _test_collector.add_script(script) + + +# ------------------------------------------------------------------------------ +# Add all scripts in the specified directory that start with the prefix and end +# with the suffix. Does not look in sub directories. Can be called multiple +# times. +# ------------------------------------------------------------------------------ +func add_directory(path, prefix=_file_prefix, suffix=".gd"): + # check for '' b/c the calls to addin the exported directories 1-6 will pass + # '' if the field has not been populated. This will cause res:// to be + # processed which will include all files if include_subdirectories is true. + if(path == '' or path == null): + return + + var dir = DirAccess.open(path) + if(dir == null): + _lgr.error(str('The path [', path, '] does not exist.')) + else: + var files = _get_files(path, prefix, suffix) + for i in range(files.size()): + if(_script_name == null or _script_name == '' or \ + (_script_name != null and files[i].findn(_script_name) != -1)): + add_script(files[i]) + + +# ------------------------------------------------------------------------------ +# This will try to find a script in the list of scripts to test that contains +# the specified script name. It does not have to be a full match. It will +# select the first matching occurrence so that this script will run when run_tests +# is called. Works the same as the select_this_one option of add_script. +# +# returns whether it found a match or not +# ------------------------------------------------------------------------------ +func select_script(script_name): + _script_name = script_name + _select_script = script_name + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func export_tests(path=_export_path): + if(path == null): + _lgr.error('You must pass a path or set the export_path before calling export_tests') + else: + var result = _test_collector.export_tests(path) + if(result): + _lgr.info(_test_collector.to_s()) + _lgr.info("Exported to " + path) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func import_tests(path=_export_path): + if(!FileAccess.file_exists(path)): + _lgr.error(str('Cannot import tests: the path [', path, '] does not exist.')) + else: + _test_collector.clear() + var result = _test_collector.import_tests(path) + if(result): + _lgr.info("\n" + _test_collector.to_s()) + _lgr.info("Imported from " + path) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func import_tests_if_none_found(): + if(!_cancel_import and _test_collector.scripts.size() == 0): + import_tests() + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func export_if_tests_found(): + if(_test_collector.scripts.size() > 0): + export_tests() + +# --------------------------- +# +# MISC +# +# --------------------------- + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func maximize(): + _lgr.deprecated('gut.maximize') + + +# ------------------------------------------------------------------------------ +# Clears the text of the text box. This resets all counters. +# ------------------------------------------------------------------------------ +func clear_text(): + _lgr.deprecated('gut.clear_text') + + +# ------------------------------------------------------------------------------ +# Get the number of tests that were ran +# ------------------------------------------------------------------------------ +func get_test_count(): + return _test_collector.get_ran_test_count() + +# ------------------------------------------------------------------------------ +## Get the number of assertions that were made +func get_assert_count(): + return _test_collector.get_assert_count() + +# ------------------------------------------------------------------------------ +## Get the number of assertions that passed +func get_pass_count(): + return _test_collector.get_pass_count() + +# ------------------------------------------------------------------------------ +## Get the number of assertions that failed +func get_fail_count(): + return _test_collector.get_fail_count() + +# ------------------------------------------------------------------------------ +## Get the number of tests flagged as pending +func get_pending_count(): + return _test_collector.get_pending_count() + + +# ------------------------------------------------------------------------------ +# Call this method to make the test pause before teardown so that you can inspect +# anything that you have rendered to the screen. +# ------------------------------------------------------------------------------ +func pause_before_teardown(): + _pause_before_teardown = true; + + +# ------------------------------------------------------------------------------ +# Returns the script object instance that is currently being run. +# ------------------------------------------------------------------------------ +func get_current_script_object(): + var to_return = null + if(_test_script_objects.size() > 0): + to_return = _test_script_objects[-1] + return to_return + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_current_test_object(): + return _current_test + + +## Returns a summary.gd object that contains all the information about +## the run results. +func get_summary(): + return GutUtils.Summary.new(self) + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_pre_run_script_instance(): + return _pre_run_script_instance + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_post_run_script_instance(): + return _post_run_script_instance + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func show_orphans(should): + _lgr.set_type_enabled(_lgr.types.orphan, should) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_logger(): + return _lgr + + +# ------------------------------------------------------------------------------ +## Returns the number of test scripts. Inner Test classes each count as a +## script. +func get_test_script_count(): + return _test_script_objects.size() + + + + +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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/gut/gut.gd.uid b/addons/gut/gut.gd.uid new file mode 100644 index 0000000..8b84514 --- /dev/null +++ b/addons/gut/gut.gd.uid @@ -0,0 +1 @@ +uid://duvvxvj18c04d diff --git a/addons/gut/gut_cmdln.gd b/addons/gut/gut_cmdln.gd new file mode 100644 index 0000000..e394838 --- /dev/null +++ b/addons/gut/gut_cmdln.gd @@ -0,0 +1,71 @@ +# ------------------------------------------------------------------------------ +# Description +# ----------- +# Entry point for the command line interface. The actual logic for GUT's CLI +# is in addons/gut/cli/gut_cli.gd. +# +# This script should conform to, or ignore, the strictest warning settings. +# ------------------------------------------------------------------------------ +extends SceneTree + +var VersionConversion = load("res://addons/gut/version_conversion.gd") + +@warning_ignore("unsafe_method_access") +@warning_ignore("inferred_declaration") +func _init() -> void: + if(VersionConversion.error_if_not_all_classes_imported()): + quit(0) + return + + var max_iter := 20 + var iter := 0 + + var Loader : Object = load("res://addons/gut/gut_loader.gd") + + # Not seen this wait more than 1. + while(Engine.get_main_loop() == null and iter < max_iter): + await create_timer(.01).timeout + iter += 1 + + if(Engine.get_main_loop() == null): + push_error('Main loop did not start in time.') + quit(0) + return + + var cli : Node = load('res://addons/gut/cli/gut_cli.gd').new() + get_root().add_child(cli) + + Loader.restore_ignore_addons() + cli.main() + + + + +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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/gut/gut_cmdln.gd.uid b/addons/gut/gut_cmdln.gd.uid new file mode 100644 index 0000000..4766ae7 --- /dev/null +++ b/addons/gut/gut_cmdln.gd.uid @@ -0,0 +1 @@ +uid://bxw30gwbwnh55 diff --git a/addons/gut/gut_config.gd b/addons/gut/gut_config.gd new file mode 100644 index 0000000..0d4f949 --- /dev/null +++ b/addons/gut/gut_config.gd @@ -0,0 +1,236 @@ +# ############################################################################## +# +# This holds all the configuratoin values for GUT. It can load and save values +# to a json file. It is also responsible for applying these settings to GUT. +# +# ############################################################################## +const FAIL_ERROR_TYPE_ENGINE = &'engine' +const FAIL_ERROR_TYPE_PUSH_ERROR = &'push_error' +const FAIL_ERROR_TYPE_GUT = &'gut' + + + +var valid_fonts = GutUtils.gut_fonts.get_font_names() +var _deprecated_values = { + "errors_do_not_cause_failure": "Use failure_error_types instead." +} + +var default_options = { + background_color = Color(.15, .15, .15, 1).to_html(), + config_file = 'res://.gutconfig.json', + # used by editor to handle enabled/disabled dirs. All dirs configured go + # here and only the enabled dirs go into dirs + configured_dirs = [], + dirs = [], + disable_colors = false, + # double strategy can be the name of the enum value, the enum value or + # lowercase name with spaces: 0/SCRIPT_ONLY/script only + # The GUI gut config expects the value to be the enum value and not a string + # when saved. + double_strategy = 'SCRIPT_ONLY', + font_color = Color(.8, .8, .8, 1).to_html(), + font_name = GutUtils.gut_fonts.DEFAULT_CUSTOM_FONT_NAME, + font_size = 16, + hide_orphans = false, + ignore_pause = false, + include_subdirs = false, + inner_class = '', + junit_xml_file = '', + junit_xml_timestamp = false, + log_level = 1, + opacity = 100, + paint_after = .1, + post_run_script = '', + pre_run_script = '', + prefix = 'test_', + selected = '', + should_exit_on_success = false, + should_exit = false, + should_maximize = false, + compact_mode = false, + show_help = false, + suffix = '.gd', + tests = [], + unit_test_name = '', + + no_error_tracking = false, + failure_error_types = ["engine", "gut", "push_error"], + wait_log_delay = .5, + + gut_on_top = true, +} + + +var options = default_options.duplicate() +var logger = GutUtils.get_logger() + +func _null_copy(h): + var new_hash = {} + for key in h: + new_hash[key] = null + return new_hash + + +func _load_options_from_config_file(file_path, into): + if(!FileAccess.file_exists(file_path)): + # Default files are ok to be missing. Maybe this is too deep a place + # to implement this, but here it is. + if(file_path != 'res://.gutconfig.json' and file_path != GutUtils.EditorGlobals.editor_run_gut_config_path): + logger.error(str('Config File "', file_path, '" does not exist.')) + return -1 + else: + return 1 + + var f = FileAccess.open(file_path, FileAccess.READ) + if(f == null): + var result = FileAccess.get_open_error() + logger.error(str("Could not load data ", file_path, ' ', result)) + return result + + var json = f.get_as_text() + f = null # close file + + var test_json_conv = JSON.new() + test_json_conv.parse(json) + var results = test_json_conv.get_data() + # SHORTCIRCUIT + if(results == null): + logger.error(str("Could not parse file: ", file_path)) + return -1 + + # Get all the options out of the config file using the option name. The + # options hash is now the default source of truth for the name of an option. + _load_dict_into(results, into) + + return 1 + + +func _load_dict_into(source, dest): + for key in dest: + if(source.has(key)): + if(source[key] != null): + if(typeof(source[key]) == TYPE_DICTIONARY): + _load_dict_into(source[key], dest[key]) + else: + dest[key] = source[key] + + +# Apply all the options specified to tester. This is where the rubber meets +# the road. +func _apply_options(opts, gut): + for entry in _deprecated_values.keys(): + if(opts.has(entry)): + # Use gut.logger instead of our own for testing purposes. + logger.deprecated(str('Config value "', entry, '" is deprecated. ', _deprecated_values[entry])) + + gut.include_subdirectories = opts.include_subdirs + + if(opts.inner_class != ''): + gut.inner_class_name = opts.inner_class + gut.log_level = opts.log_level + gut.ignore_pause_before_teardown = opts.ignore_pause + + gut.select_script(opts.selected) + + for i in range(opts.dirs.size()): + gut.add_directory(opts.dirs[i], opts.prefix, opts.suffix) + + for i in range(opts.tests.size()): + gut.add_script(opts.tests[i]) + + # Sometimes it is the index, sometimes it's a string. This sets it regardless + gut.double_strategy = GutUtils.get_enum_value( + opts.double_strategy, GutUtils.DOUBLE_STRATEGY, + GutUtils.DOUBLE_STRATEGY.SCRIPT_ONLY) + + gut.unit_test_name = opts.unit_test_name + gut.pre_run_script = opts.pre_run_script + gut.post_run_script = opts.post_run_script + gut.color_output = !opts.disable_colors + gut.show_orphans(!opts.hide_orphans) + gut.junit_xml_file = opts.junit_xml_file + gut.junit_xml_timestamp = opts.junit_xml_timestamp + gut.paint_after = str(opts.paint_after).to_float() + gut.wait_log_delay = opts.wait_log_delay + + # These error_tracker options default to true. Don't trust this comment. + if(!opts.failure_error_types.has(FAIL_ERROR_TYPE_ENGINE)): + gut.error_tracker.treat_engine_errors_as = GutUtils.TREAT_AS.NOTHING + + if(!opts.failure_error_types.has(FAIL_ERROR_TYPE_PUSH_ERROR)): + gut.error_tracker.treat_push_error_as = GutUtils.TREAT_AS.NOTHING + + if(!opts.failure_error_types.has(FAIL_ERROR_TYPE_GUT)): + gut.error_tracker.treat_gut_errors_as = GutUtils.TREAT_AS.NOTHING + + gut.error_tracker.register_loggers = !opts.no_error_tracking + + return gut + + +# -------------------------- +# Public +# -------------------------- +func write_options(path): + var content = JSON.stringify(options, ' ') + + var f = FileAccess.open(path, FileAccess.WRITE) + var result = FileAccess.get_open_error() + if(f != null): + f.store_string(content) + f = null # closes file + else: + logger.error(str("Could not open file ", path, ' ', result)) + return result + + +# consistent name +func save_file(path): + write_options(path) + + +func load_options(path): + return _load_options_from_config_file(path, options) + + +# consistent name +func load_file(path): + return load_options(path) + + +func load_options_no_defaults(path): + options = _null_copy(default_options) + return _load_options_from_config_file(path, options) + + +func apply_options(gut): + _apply_options(options, gut) + + + + +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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/gut/gut_config.gd.uid b/addons/gut/gut_config.gd.uid new file mode 100644 index 0000000..a164476 --- /dev/null +++ b/addons/gut/gut_config.gd.uid @@ -0,0 +1 @@ +uid://bobi58361x1ya diff --git a/addons/gut/gut_fonts.gd b/addons/gut/gut_fonts.gd new file mode 100644 index 0000000..875aef6 --- /dev/null +++ b/addons/gut/gut_fonts.gd @@ -0,0 +1,94 @@ +# ------------------------------------------------------------------------------ +# There was an error that someone found in Godot 4.4.1, but ended up being a +# different error in Godot 4.5. The fix was to hold a reference to the font +# so that TextEdit control did not lose the font when switching. This is +# the solution I came up with. Just hold a reference to all fonts we use, +# but only when we use them. Basically a lazy loader with some semantics for +# font names and location. +# +# https://github.com/bitwes/Gut/issues/749 +# +# An instance of this could be used to allow users to specify their own fonts. +# It's not perect for that yet, but it is feasible. +# ------------------------------------------------------------------------------ +const DEFAULT_CUSTOM_FONT_NAME = 'CourierPrime' +const THEME_FONT_TO_FONT_TYPES_MAP = { + 'font':FONT_TYPES.REGULAR, + 'normal_font': FONT_TYPES.REGULAR, + 'bold_font': FONT_TYPES.BOLD, + 'italics_font':FONT_TYPES.ITALIC, + 'bold_italics_font':FONT_TYPES.BOLD_ITALIC +} + + +# Values for FONT_TYPES are based on Google font file suffix (not extension). +# A font file will be a key from fonts + - + FONT_TYPE value + .ttf. +const FONT_TYPES = { + REGULAR = 'Regular', + BOLD = 'Bold', + ITALIC = 'Italic', + BOLD_ITALIC = 'BoldItalic' +} + + +var fonts = { + 'AnonymousPro':{}, + 'CourierPrime':{}, + 'LobsterTwo':{}, + 'Default':{} +} + + +var custom_font_path = 'res://addons/gut/fonts/' + + +func _init(): + _populate_default_fonts() + + +func _populate_default_fonts(): + var ctrl = TextEdit.new() + var f = ctrl.get_theme_font('font') + for key in FONT_TYPES: + fonts['Default'][FONT_TYPES[key]] = f + ctrl.free() + + +func _load_font(font_name, font_type, font_path): + var dynamic_font = FontFile.new() + dynamic_font.load_dynamic_font(font_path) + fonts[font_name][font_type] = dynamic_font + + +func get_font(font_name, font_type='Regular'): + if(!fonts.has(font_name)): + push_error(str("Invalid font name '", font_name, "'")) + return fonts['Default'][FONT_TYPES.REGULAR] + + if(!FONT_TYPES.values().has(font_type)): + push_error(str("Invalid font type '", font_type, "'")) + return fonts['Default'][FONT_TYPES.REGULAR] + + if(!fonts[font_name].has(font_type)): + var filename = custom_font_path.path_join(str(font_name, '-', font_type, '.ttf')) + if(FileAccess.file_exists(filename)): + _load_font(font_name, font_type, filename) + else: + push_error(str("Missing custom font ", filename)) + return fonts['Default'][FONT_TYPES.REGULAR] + + return fonts.get(font_name, {}).get(font_type, null) + + +func get_font_names(): + return fonts.keys() + + +# Maps the various theme font names (font, normal_font, italics_font etc) to +# a FONT_TYPE. +func get_font_for_theme_font_name(theme_font_name, custom_font_name): + if(!THEME_FONT_TO_FONT_TYPES_MAP.has(theme_font_name)): + push_error(str("Unknown theme font name ", theme_font_name)) + return get_font(custom_font_name) + return get_font(custom_font_name, THEME_FONT_TO_FONT_TYPES_MAP[theme_font_name]) + diff --git a/addons/gut/gut_fonts.gd.uid b/addons/gut/gut_fonts.gd.uid new file mode 100644 index 0000000..12d85fb --- /dev/null +++ b/addons/gut/gut_fonts.gd.uid @@ -0,0 +1 @@ +uid://dvajwe2cllerq diff --git a/addons/gut/gut_loader.gd b/addons/gut/gut_loader.gd new file mode 100644 index 0000000..c7fbe3d --- /dev/null +++ b/addons/gut/gut_loader.gd @@ -0,0 +1,105 @@ +# ------------------------------------------------------------------------------ +# This script should be loaded as soon as possible when running tests. This +# will disable warnings and then load all scripts that are registered with the +# LazyLoader. +# +# Once you are ready to run tests, restore_ignore_addons should be called so +# that it has the expected value. This should be done after whatever loaded +# this is done loading and doing setup stuff. +# +# This was created after a first attempt to suppress all GUT warnings did not +# work for the strictest warning settings. This has turned the LazyLoader into +# just a Loader...so maybe all that should be reworked or renamed. A problem +# for a time when we are absolutely sure that all warnings are being correctly +# suppressed I suppose. +# +# You can use the cli script test/resources/change_project_warnings.gd to +# quickly alter project warning levels for testing purposes. +# gdscript test/resources/change_project_warnings.gd --headless ++ -h +# +# You can set project warning settings from the command line with: +# godot -s addons/gut/cli/change_project_warnings.gd ++ -h +# +# This script should conform to, or ignore, the strictest warning settings. +# ------------------------------------------------------------------------------ +const WARNING_PATH : String = 'debug/gdscript/warnings/' + + +static var were_addons_disabled : bool = true + + +@warning_ignore("unsafe_method_access") +@warning_ignore("unsafe_property_access") +@warning_ignore("untyped_declaration") +static func _static_init() -> void: + were_addons_disabled = ProjectSettings.get(str(WARNING_PATH, 'exclude_addons')) + ProjectSettings.set(str(WARNING_PATH, 'exclude_addons'), true) + + var WarningsManager = load('res://addons/gut/warnings_manager.gd') + + # Turn everything back on (if it originally was on) if the warnings manager + # is disabled. This makes sure we see all the warnings for all the scripts + # in the LazyLoader (except WarningsManager, but that's not a big deal). + # + # With the warnings manager disabled and all_warn warnings: + # test_warnings_manager.gd -> 5471 errors + # full run -> 131,742 errors + # + # With the warnings manager disabled and gut_default warnings: + # test_warnings_manager.gd -> 46 errors + # full run -> 165 errors. + if(WarningsManager.disabled): + ProjectSettings.set(str(WARNING_PATH, 'exclude_addons'), were_addons_disabled) + + # Force a reference to utils.gd by path. Using the class_name would cause + # utils.gd to load when this script loads, before we could turn off the + # warnings. + var _utils : Object = load('res://addons/gut/utils.gd') + + # Since load_all exists on the LazyLoader, it should be done now so nothing + # sneaks in later...This essentially defeats the "lazy" part of the + # LazyLoader, but not the "loader" part of LazyLoader. + _utils.LazyLoader.load_all() + + # Make sure that the values set in WarningsManager's static_init actually + # reflect the project settings and not whatever we do here to make things + # not warn. + WarningsManager._project_warnings.exclude_addons = were_addons_disabled + + +# this can be called before tests are run to reinstate whatever exclude_addons +# was set to before this script disabled it. +static func restore_ignore_addons() -> void: + ProjectSettings.set(str(WARNING_PATH, 'exclude_addons'), were_addons_disabled) + + + + +# ############################################################################## +# (G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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/gut/gut_loader.gd.uid b/addons/gut/gut_loader.gd.uid new file mode 100644 index 0000000..de5b25d --- /dev/null +++ b/addons/gut/gut_loader.gd.uid @@ -0,0 +1 @@ +uid://2jdhrg7xws31 diff --git a/addons/gut/gut_loader_the_scene.tscn b/addons/gut/gut_loader_the_scene.tscn new file mode 100644 index 0000000..8fbd010 --- /dev/null +++ b/addons/gut/gut_loader_the_scene.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=2 format=3 uid="uid://jt6wsefn0x54"] + +[sub_resource type="Resource" id="Resource_cayac"] +metadata/__load_path__ = "res://addons/gut/gut_loader_the_scene.gd" + +[node name="Node" type="Node2D"] +script = SubResource("Resource_cayac") diff --git a/addons/gut/gut_menu.gd b/addons/gut/gut_menu.gd new file mode 100644 index 0000000..2d4c933 --- /dev/null +++ b/addons/gut/gut_menu.gd @@ -0,0 +1,103 @@ +var sub_menu : PopupMenu = null + +var _menus = { + # name : { + # index, + # id, + # callback + # } +} + +signal about +signal rerun +signal run_all +signal run_at_cursor +signal run_inner_class +signal run_script +signal run_test +signal show_gut +signal toggle_windowed + + +func _init(): + sub_menu = PopupMenu.new() + sub_menu.index_pressed.connect(_on_sub_menu_index_pressed) + make_menu() + + +func _invalid_index(): + print("bad menu index") + + +func _on_sub_menu_index_pressed(index): + var to_call : Callable = _invalid_index + for key in _menus: + if(_menus[key].index == index): + to_call = _menus[key].callback + + to_call.call() + + +func add_menu(display_text, sig_to_emit, tooltip=''): + var index = sub_menu.item_count + _menus[sig_to_emit.get_name()] = { + index = index, + id = index, + callback = sig_to_emit.emit + } + sub_menu.add_item(display_text, index) + sub_menu.set_item_tooltip(index, tooltip) + return index + + + +func make_menu(): + add_menu("Toggle Windowed", toggle_windowed, + 'Toggle GUT in the dock or a floating window') + add_menu("Show/Hide GUT", show_gut, '') + + sub_menu.add_separator('Run') + add_menu("Run All", run_all, + "Run all tests") + add_menu("Run Script", run_script, + "Run the currently selected script") + add_menu("Run Inner Class", run_inner_class, + "Run the currently selected inner test class") + add_menu("Run Test", run_test, + "Run the currently selected test") + add_menu("Run At Cursor", run_at_cursor, + "Run the most specific of script/inner class/test based on cursor position") + add_menu("Rerun", rerun, "Rerun the last test(s) ran", ) + + sub_menu.add_separator() + add_menu("About", about, 'All about GUT') + + +func set_shortcut(menu_name, accel_or_input_key): + if(typeof(accel_or_input_key) == TYPE_INT): + sub_menu.set_item_accelerator(_menus[menu_name].index, accel_or_input_key) + elif(typeof(accel_or_input_key) == TYPE_OBJECT and accel_or_input_key is InputEventKey): + sub_menu.set_item_accelerator(_menus[menu_name].index, accel_or_input_key.get_keycode_with_modifiers()) + + +func disable_menu(menu_name, disabled): + sub_menu.set_item_disabled(_menus[menu_name].index, disabled) + + +func apply_gut_shortcuts(shortcut_dialog): + set_shortcut("show_gut", + shortcut_dialog.scbtn_panel.get_input_event()) + set_shortcut("run_all", + shortcut_dialog.scbtn_run_all.get_input_event()) + set_shortcut("run_script", + shortcut_dialog.scbtn_run_current_script.get_input_event()) + set_shortcut("run_inner_class", + shortcut_dialog.scbtn_run_current_inner.get_input_event()) + set_shortcut("run_test", + shortcut_dialog.scbtn_run_current_test.get_input_event()) + set_shortcut("run_at_cursor", + shortcut_dialog.scbtn_run_at_cursor.get_input_event()) + set_shortcut("rerun", + shortcut_dialog.scbtn_rerun.get_input_event()) + set_shortcut("toggle_windowed", + shortcut_dialog.scbtn_windowed.get_input_event()) diff --git a/addons/gut/gut_menu.gd.uid b/addons/gut/gut_menu.gd.uid new file mode 100644 index 0000000..53a33c7 --- /dev/null +++ b/addons/gut/gut_menu.gd.uid @@ -0,0 +1 @@ +uid://crhdyu6u7n8c4 diff --git a/addons/gut/gut_plugin.gd b/addons/gut/gut_plugin.gd new file mode 100644 index 0000000..e0ae201 --- /dev/null +++ b/addons/gut/gut_plugin.gd @@ -0,0 +1,143 @@ +@tool +extends EditorPlugin + +var VersionConversion = load("res://addons/gut/version_conversion.gd") +var MenuManager = load("res://addons/gut/gut_menu.gd") +var GutWindow = load("res://addons/gut/gui/GutEditorWindow.tscn") +var BottomPanelScene = preload('res://addons/gut/gui/GutBottomPanel.tscn') +var GutEditorGlobals = load('res://addons/gut/gui/editor_globals.gd') + +var _bottom_panel : Control = null +var _menu_mgr = null +var _gut_button = null +var _gut_window = null +var _dock_mode = 'none' + + +func _init(): + if(VersionConversion.error_if_not_all_classes_imported()): + return + + +func _enter_tree(): + if(!_version_conversion()): + return + + _bottom_panel = BottomPanelScene.instantiate() + gut_as_panel() + + # --------- + # I removed this delay because it was causing issues with the shortcut button. + # The shortcut button wouldn't work right until load_shortcuts is called., but + # the delay gave you 3 seconds to click it before they were loaded. This + # await came with the conversion to 4 and probably isn't needed anymore. + # I'm leaving it here becuase I don't know why it showed up to begin with + # and if it's needed, it will be pretty hard to debug without seeing this. + # + # This should be deleted after the next release or two if not needed. + # + # I added it back in when doing the window stuff. Starting in a window + # made it angry (don't remember how) until I added it back in. + await get_tree().create_timer(1).timeout + # --- + + _bottom_panel.set_interface(get_editor_interface()) + _bottom_panel.set_plugin(self) + _bottom_panel.load_shortcuts() + + _menu_mgr = MenuManager.new() + _bottom_panel._ctrls.run_at_cursor.menu_manager = _menu_mgr + _bottom_panel.menu_manager = _menu_mgr + add_tool_submenu_item("GUT", _menu_mgr.sub_menu) + + GutEditorGlobals.gut_plugin = self + + + +func _version_conversion(): + var EditorGlobals = load("res://addons/gut/gui/editor_globals.gd") + EditorGlobals.create_temp_directory() + + if(VersionConversion.error_if_not_all_classes_imported()): + return false + + VersionConversion.convert() + return true + + +func gut_as_window(): + if(_gut_window == null): + _gut_window = GutWindow.instantiate() + _gut_window.gut_plugin = self + add_child(_gut_window) + _gut_window.theme = get_tree().root.theme + _gut_window.interface = get_editor_interface() + + _gut_window.add_gut_panel(_bottom_panel) + _bottom_panel.make_floating_btn.visible = false + _gut_button = null + _dock_mode = 'window' + + +func gut_as_panel(): + _gut_button = add_control_to_bottom_panel(_bottom_panel, 'GUT') + _bottom_panel.set_panel_button(_gut_button) + _gut_button.shortcut_in_tooltip = true + _dock_mode = 'panel' + _bottom_panel._apply_shortcuts() + _bottom_panel.results_horiz_layout() + _bottom_panel.make_floating_btn.visible = true + + if(_gut_window != null): + _gut_window.queue_free() + _gut_window = null + + +func toggle_windowed(): + _deparent_bottom_panel() + if(_dock_mode == 'window' or _dock_mode == 'none'): + gut_as_panel() + elif(_dock_mode == 'panel'): + gut_as_window() + _bottom_panel.show_me() + + +func _deparent_bottom_panel(): + if(_dock_mode == 'window'): + _gut_window.remove_panel() + elif(_dock_mode == 'panel'): + remove_control_from_bottom_panel(_bottom_panel) + + + +func _exit_tree(): + remove_tool_menu_item("GUT") + _menu_mgr = null + GutEditorGlobals.user_prefs.save_it() + # Clean-up of the plugin goes here + # Always remember to remove_at it from the engine when deactivated + _deparent_bottom_panel() + if(_gut_window != null): + _gut_window.queue_free() + + _bottom_panel.menu_manager = null + _bottom_panel.queue_free() + + remove_tool_menu_item("GUT") # made by _menu_mgr + + +func show_output_panel(): + if(_bottom_panel == null): + return + + var panel = null + var kids = _bottom_panel.get_parent().get_children() + var idx = 0 + + while(idx < kids.size() and panel == null): + if(str(kids[idx]).contains(" String: + return str("CODE:", code, " TYPE:", error_type, " RATIONALE:", rationale, "\n", + file, '->', function, '@', line, "\n", + backtrace, "\n") + + +## Returns [code]true[/code] if the error is a push_error. +func is_push_error(): + return error_type != GutUtils.GUT_ERROR_TYPE and function == "push_error" + + +## Returns [code]true[/code] if the error is an engine error. This includes +## all errors that pass through the [Logger] that do not originate from the +## [code]push_error[/code] function. +func is_engine_error(): + return error_type != GutUtils.GUT_ERROR_TYPE and !is_push_error() + + +## Returns [code]true[/code] if the error is a GUT error. Some fields may not +## be populated for GUT errors. +func is_gut_error(): + return error_type == GutUtils.GUT_ERROR_TYPE + + +func contains_text(text): + return code.to_lower().find(text.to_lower()) != -1 or \ + rationale.to_lower().find(text.to_lower()) != -1 + + +## For display purposes only, the actual value returned may change over time. +## This returns a name for the error_type as far as this class is concerned. +## Use the various [code]is_[/code] methods to check if an error is a certain +## type. +func get_error_type_name(): + var to_return = "Unknown" + + if(is_gut_error()): + to_return = "GUT" + elif(is_push_error()): + to_return = "push_error" + elif(is_engine_error()): + to_return = str("engine-", error_type) + + return to_return + + +# this might not work in other languages, and feels falkey, but might be +# useful at some point. +# func is_assert(): +# return error_type == Logger.ERROR_TYPE_SCRIPT and \ +# (code.find("Assertion failed.") == 0 or \ +# code.find("Assertion failed:") == 0) \ No newline at end of file diff --git a/addons/gut/gut_tracked_error.gd.uid b/addons/gut/gut_tracked_error.gd.uid new file mode 100644 index 0000000..de081ab --- /dev/null +++ b/addons/gut/gut_tracked_error.gd.uid @@ -0,0 +1 @@ +uid://c1m2dbkoyf4fn diff --git a/addons/gut/gut_vscode_debugger.gd b/addons/gut/gut_vscode_debugger.gd new file mode 100644 index 0000000..03f04bd --- /dev/null +++ b/addons/gut/gut_vscode_debugger.gd @@ -0,0 +1,43 @@ +# ------------------------------------------------------------------------------ +# Entry point for using the debugger through VSCode. The gut-extension for +# VSCode launches this instead of gut_cmdln.gd when running tests through the +# debugger. +# +# This could become more complex overtime, but right now all we have to do is +# to make sure the console printer is enabled or you do not get any output. +# ------------------------------------------------------------------------------ +extends 'res://addons/gut/gut_cmdln.gd' + +func run_tests(runner): + runner.get_gut().get_logger().disable_printer('console', false) + runner.run_tests() + + +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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/gut/gut_vscode_debugger.gd.uid b/addons/gut/gut_vscode_debugger.gd.uid new file mode 100644 index 0000000..a470c06 --- /dev/null +++ b/addons/gut/gut_vscode_debugger.gd.uid @@ -0,0 +1 @@ +uid://hflec26434u5 diff --git a/addons/gut/hook_script.gd b/addons/gut/hook_script.gd new file mode 100644 index 0000000..589db13 --- /dev/null +++ b/addons/gut/hook_script.gd @@ -0,0 +1,53 @@ +class_name GutHookScript +## This script is the base for custom scripts to be used in pre and post +## run hooks. +## +## GUT Wiki: [url=https://gut.readthedocs.io]https://gut.readthedocs.io[/url] +## [br][br] +## Creating a hook script requires that you:[br] +## - Inherit [code skip-lint]GutHookScript[/code][br] +## - Implement a [code skip-lint]run()[/code] method[br] +## - Configure the path in GUT (gutconfig and/or editor) as the approparite hook (pre or post).[br] +## +## See [wiki]Hooks[/wiki] + + +## Class responsible for generating xml. You could use this to generate XML +## yourself instead of using the built in GUT xml generation options. See +## [addons/gut/junit_xml_export.gd] +var JunitXmlExport = load('res://addons/gut/junit_xml_export.gd') + +## This is the instance of [GutMain] that is running the tests. You can get +## information about the run from this object. This is set by GUT when the +## script is instantiated. +var gut = null + +# the exit code to be used by gut_cmdln. See set method. +var _exit_code = null + +var _should_abort = false + +## Virtual method that will be called by GUT after instantiating this script. +## This is where you put all of your logic. +func run(): + gut.logger.error("Run method not overloaded. Create a 'run()' method in your hook script to run your code.") + + +## Set the exit code when running from the command line. If not set then the +## default exit code will be returned (0 when no tests fail, 1 when any tests +## fail). +func set_exit_code(code : int): + _exit_code = code + +## Returns the exit code set with [code skip-lint]set_exit_code[/code] +func get_exit_code(): + return _exit_code + +## Usable by pre-run script to cause the run to end AFTER the run() method +## finishes. GUT will quit and post-run script will not be ran. +func abort(): + _should_abort = true + +## Returns if [code skip-lint]abort[/code] was called. +func should_abort(): + return _should_abort diff --git a/addons/gut/hook_script.gd.uid b/addons/gut/hook_script.gd.uid new file mode 100644 index 0000000..a24103b --- /dev/null +++ b/addons/gut/hook_script.gd.uid @@ -0,0 +1 @@ +uid://1i8mr2knypot diff --git a/addons/gut/icon.png b/addons/gut/icon.png new file mode 100644 index 0000000..9ad6a1d Binary files /dev/null and b/addons/gut/icon.png differ diff --git a/addons/gut/icon.png.import b/addons/gut/icon.png.import new file mode 100644 index 0000000..e127d29 --- /dev/null +++ b/addons/gut/icon.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bvo0uao7deu0q" +path="res://.godot/imported/icon.png-91b084043b8aaf2f1c906e7b9fa92969.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/icon.png" +dest_files=["res://.godot/imported/icon.png-91b084043b8aaf2f1c906e7b9fa92969.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/gut/images/Folder.svg b/addons/gut/images/Folder.svg new file mode 100644 index 0000000..00ff7a9 --- /dev/null +++ b/addons/gut/images/Folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/gut/images/Folder.svg.import b/addons/gut/images/Folder.svg.import new file mode 100644 index 0000000..2ac0e4a --- /dev/null +++ b/addons/gut/images/Folder.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhvl14hls3y2j" +path="res://.godot/imported/Folder.svg-caa50e6a0be9d456fd81991dfb537916.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/images/Folder.svg" +dest_files=["res://.godot/imported/Folder.svg-caa50e6a0be9d456fd81991dfb537916.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 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/gut/images/GutIconV2_base.png b/addons/gut/images/GutIconV2_base.png new file mode 100644 index 0000000..ff4caff Binary files /dev/null and b/addons/gut/images/GutIconV2_base.png differ diff --git a/addons/gut/images/GutIconV2_base.png.import b/addons/gut/images/GutIconV2_base.png.import new file mode 100644 index 0000000..a7e5ce4 --- /dev/null +++ b/addons/gut/images/GutIconV2_base.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dyxbmyvpkkcvs" +path="res://.godot/imported/GutIconV2_base.png-7038b445a6dfa6ba4cec1a42fa81abae.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/images/GutIconV2_base.png" +dest_files=["res://.godot/imported/GutIconV2_base.png-7038b445a6dfa6ba4cec1a42fa81abae.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/gut/images/GutIconV2_no_shine.png b/addons/gut/images/GutIconV2_no_shine.png new file mode 100644 index 0000000..4d917ba Binary files /dev/null and b/addons/gut/images/GutIconV2_no_shine.png differ diff --git a/addons/gut/images/GutIconV2_no_shine.png.import b/addons/gut/images/GutIconV2_no_shine.png.import new file mode 100644 index 0000000..15739a1 --- /dev/null +++ b/addons/gut/images/GutIconV2_no_shine.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dfaxjxo6gx7d4" +path="res://.godot/imported/GutIconV2_no_shine.png-340880100fb7bae1a2e0530330841e58.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/images/GutIconV2_no_shine.png" +dest_files=["res://.godot/imported/GutIconV2_no_shine.png-340880100fb7bae1a2e0530330841e58.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/gut/images/HSplitContainer.svg b/addons/gut/images/HSplitContainer.svg new file mode 100644 index 0000000..8def1c0 --- /dev/null +++ b/addons/gut/images/HSplitContainer.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/addons/gut/images/HSplitContainer.svg.import b/addons/gut/images/HSplitContainer.svg.import new file mode 100644 index 0000000..37a460a --- /dev/null +++ b/addons/gut/images/HSplitContainer.svg.import @@ -0,0 +1,44 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ljc2viafngwd" +path="res://.godot/imported/HSplitContainer.svg-81e594d94ee80e4176e099672ef6025b.ctex" +metadata={ +"has_editor_variant": true, +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/images/HSplitContainer.svg" +dest_files=["res://.godot/imported/HSplitContainer.svg-81e594d94ee80e4176e099672ef6025b.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 +svg/scale=1.0 +editor/scale_with_editor_scale=true +editor/convert_colors_with_editor_theme=false diff --git a/addons/gut/images/Script.svg b/addons/gut/images/Script.svg new file mode 100644 index 0000000..b105e17 --- /dev/null +++ b/addons/gut/images/Script.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/gut/images/Script.svg.import b/addons/gut/images/Script.svg.import new file mode 100644 index 0000000..8dc4fbf --- /dev/null +++ b/addons/gut/images/Script.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cavojn74qp7ij" +path="res://.godot/imported/Script.svg-34c66aae9c985e3e0470426acbbcda04.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/images/Script.svg" +dest_files=["res://.godot/imported/Script.svg-34c66aae9c985e3e0470426acbbcda04.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 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/gut/images/VSplitContainer.svg b/addons/gut/images/VSplitContainer.svg new file mode 100644 index 0000000..84ecd62 --- /dev/null +++ b/addons/gut/images/VSplitContainer.svg @@ -0,0 +1,39 @@ + + + + + + diff --git a/addons/gut/images/VSplitContainer.svg.import b/addons/gut/images/VSplitContainer.svg.import new file mode 100644 index 0000000..0dd7f16 --- /dev/null +++ b/addons/gut/images/VSplitContainer.svg.import @@ -0,0 +1,44 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bhew20crsywxr" +path="res://.godot/imported/VSplitContainer.svg-6cec29fefa7e65d6ff7c2fdb9a1bb9ab.ctex" +metadata={ +"has_editor_variant": true, +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/images/VSplitContainer.svg" +dest_files=["res://.godot/imported/VSplitContainer.svg-6cec29fefa7e65d6ff7c2fdb9a1bb9ab.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 +svg/scale=1.0 +editor/scale_with_editor_scale=true +editor/convert_colors_with_editor_theme=false diff --git a/addons/gut/images/eyey.png b/addons/gut/images/eyey.png new file mode 100644 index 0000000..d3d388a Binary files /dev/null and b/addons/gut/images/eyey.png differ diff --git a/addons/gut/images/eyey.png.import b/addons/gut/images/eyey.png.import new file mode 100644 index 0000000..a30299e --- /dev/null +++ b/addons/gut/images/eyey.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dx0yxxn5q7doc" +path="res://.godot/imported/eyey.png-e0a721ab6b749f5d493d61e9f27f0654.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/images/eyey.png" +dest_files=["res://.godot/imported/eyey.png-e0a721ab6b749f5d493d61e9f27f0654.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/gut/images/green.png b/addons/gut/images/green.png new file mode 100644 index 0000000..c81fd09 Binary files /dev/null and b/addons/gut/images/green.png differ diff --git a/addons/gut/images/green.png.import b/addons/gut/images/green.png.import new file mode 100644 index 0000000..25c0770 --- /dev/null +++ b/addons/gut/images/green.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bvrnfjkcmpr8s" +path="res://.godot/imported/green.png-e3a17091688e10a7013279b38edc7f8a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/images/green.png" +dest_files=["res://.godot/imported/green.png-e3a17091688e10a7013279b38edc7f8a.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/gut/images/red.png b/addons/gut/images/red.png new file mode 100644 index 0000000..3d9dec1 Binary files /dev/null and b/addons/gut/images/red.png differ diff --git a/addons/gut/images/red.png.import b/addons/gut/images/red.png.import new file mode 100644 index 0000000..3edaaa6 --- /dev/null +++ b/addons/gut/images/red.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ba2sgost7my3x" +path="res://.godot/imported/red.png-47a557c3922e800f76686bc1a4ad0c3c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/images/red.png" +dest_files=["res://.godot/imported/red.png-47a557c3922e800f76686bc1a4ad0c3c.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/gut/images/yellow.png b/addons/gut/images/yellow.png new file mode 100644 index 0000000..8b7dc35 Binary files /dev/null and b/addons/gut/images/yellow.png differ diff --git a/addons/gut/images/yellow.png.import b/addons/gut/images/yellow.png.import new file mode 100644 index 0000000..c56b0a8 --- /dev/null +++ b/addons/gut/images/yellow.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://o4mo2w2ftx1v" +path="res://.godot/imported/yellow.png-b3cf3d463958a169d909273d3d742052.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/images/yellow.png" +dest_files=["res://.godot/imported/yellow.png-b3cf3d463958a169d909273d3d742052.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/gut/inner_class_registry.gd b/addons/gut/inner_class_registry.gd new file mode 100644 index 0000000..df83bb9 --- /dev/null +++ b/addons/gut/inner_class_registry.gd @@ -0,0 +1,67 @@ +var _registry = {} + + +func _create_reg_entry(base_path, subpath): + var to_return = { + "base_path":base_path, + "subpath":subpath, + "base_resource":load(base_path), + "full_path":str("'", base_path, "'", subpath) + } + return to_return + +func _register_inners(base_path, obj, prev_inner = ''): + var const_map = obj.get_script_constant_map() + var consts = const_map.keys() + var const_idx = 0 + + while(const_idx < consts.size()): + var key = consts[const_idx] + var thing = const_map[key] + + if(typeof(thing) == TYPE_OBJECT and thing.resource_path == ''): + var cur_inner = str(prev_inner, ".", key) + _registry[thing] = _create_reg_entry(base_path, cur_inner) + _register_inners(base_path, thing, cur_inner) + + const_idx += 1 + + +func register(base_script): + var base_path = base_script.resource_path + _register_inners(base_path, base_script) + + +func get_extends_path(inner_class): + if(_registry.has(inner_class)): + return _registry[inner_class].full_path + else: + return null + +# returns the subpath for the inner class. This includes the leading "." in +# the path. +func get_subpath(inner_class): + if(_registry.has(inner_class)): + return _registry[inner_class].subpath + else: + return '' + +func get_base_path(inner_class): + if(_registry.has(inner_class)): + return _registry[inner_class].base_path + + +func has(inner_class): + return _registry.has(inner_class) + + +func get_base_resource(inner_class): + if(_registry.has(inner_class)): + return _registry[inner_class].base_resource + + +func to_s(): + var text = "" + for key in _registry: + text += str(key, ": ", _registry[key], "\n") + return text diff --git a/addons/gut/inner_class_registry.gd.uid b/addons/gut/inner_class_registry.gd.uid new file mode 100644 index 0000000..ebb01e0 --- /dev/null +++ b/addons/gut/inner_class_registry.gd.uid @@ -0,0 +1 @@ +uid://l4hh1hhgq3kx diff --git a/addons/gut/input_factory.gd b/addons/gut/input_factory.gd new file mode 100644 index 0000000..e04b441 --- /dev/null +++ b/addons/gut/input_factory.gd @@ -0,0 +1,159 @@ +class_name GutInputFactory +## Static class full of helper methods to make InputEvent instances. +## +## This thing makes InputEvents. Enjoy. + +# Implemented InputEvent* convenience methods +# InputEventAction +# InputEventKey +# InputEventMouseButton +# InputEventMouseMotion + +# Yet to implement InputEvents +# InputEventJoypadButton +# InputEventJoypadMotion +# InputEventMagnifyGesture +# InputEventMIDI +# InputEventPanGesture +# InputEventScreenDrag +# InputEventScreenTouch + + +static func _to_scancode(which): + var key_code = which + if(typeof(key_code) == TYPE_STRING): + key_code = key_code.to_upper().to_ascii_buffer()[0] + return key_code + + +## Creates a new button with the given propoerties. +static func new_mouse_button_event(position, global_position, pressed, button_index) -> InputEventMouseButton: + var event = InputEventMouseButton.new() + event.position = position + if(global_position != null): + event.global_position = global_position + event.pressed = pressed + event.button_index = button_index + + return event + + +## Returns an [InputEventKey] event with [code]pressed = false[/code]. [param which] can be a character or a [code]KEY_*[/code] constant. +static func key_up(which) -> InputEventKey: + var event = InputEventKey.new() + event.keycode = _to_scancode(which) + event.pressed = false + return event + + +## Returns an [InputEventKey] event with [code]pressed = true[/code]. [param which] can be a character or a [code]KEY_*[/code] constant. +static func key_down(which) -> InputEventKey: + var event = InputEventKey.new() + event.keycode = _to_scancode(which) + event.pressed = true + return event + + +## Returns an "action up" [InputEventAction] instance. [param which] is the name of the action defined in the Key Map. +static func action_up(which, strength=1.0) -> InputEventAction: + var event = InputEventAction.new() + event.action = which + event.strength = strength + return event + + +## Returns an "action down" [InputEventAction] instance. [param which] is the name of the action defined in the Key Map. +static func action_down(which, strength=1.0) -> InputEventAction: + var event = InputEventAction.new() + event.action = which + event.strength = strength + event.pressed = true + return event + + +## Returns a "button down" [InputEventMouseButton] for the left mouse button. +static func mouse_left_button_down(position, global_position=null) -> InputEventMouseButton: + var event = new_mouse_button_event(position, global_position, true, MOUSE_BUTTON_LEFT) + return event + + +## Returns a "button up" [InputEventMouseButton] for the left mouse button. +static func mouse_left_button_up(position, global_position=null) -> InputEventMouseButton: + var event = new_mouse_button_event(position, global_position, false, MOUSE_BUTTON_LEFT) + return event + + +## Returns a "double click" [InputEventMouseButton] for the left mouse button. +static func mouse_double_click(position, global_position=null) -> InputEventMouseButton: + var event = new_mouse_button_event(position, global_position, false, MOUSE_BUTTON_LEFT) + event.double_click = true + return event + + +## Returns a "button down" [InputEventMouseButton] for the right mouse button. +static func mouse_right_button_down(position, global_position=null) -> InputEventMouseButton: + var event = new_mouse_button_event(position, global_position, true, MOUSE_BUTTON_RIGHT) + return event + + +## Returns a "button up" [InputEventMouseButton] for the right mouse button. +static func mouse_right_button_up(position, global_position=null) -> InputEventMouseButton: + var event = new_mouse_button_event(position, global_position, false, MOUSE_BUTTON_RIGHT) + return event + + +## Returns a [InputEventMouseMotion] to move the mouse the specified positions. +static func mouse_motion(position, global_position=null) -> InputEventMouseMotion: + var event = InputEventMouseMotion.new() + event.position = position + if(global_position != null): + event.global_position = global_position + return event + + +## Returns an [InputEventMouseMotion] that moves the mouse [param offset] +## from the last [method mouse_motion] or [method mouse_motion] call. +static func mouse_relative_motion(offset, last_motion_event=null, speed=Vector2(0, 0)) -> InputEventMouseMotion: + var event = null + if(last_motion_event == null): + event = mouse_motion(offset) + event.velocity = speed + else: + event = last_motion_event.duplicate() + event.position += offset + event.global_position += offset + event.relative = offset + event.velocity = speed + return event + +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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. +# +# ############################################################################## +# Description +# ----------- +# ############################################################################## diff --git a/addons/gut/input_factory.gd.uid b/addons/gut/input_factory.gd.uid new file mode 100644 index 0000000..df4fdec --- /dev/null +++ b/addons/gut/input_factory.gd.uid @@ -0,0 +1 @@ +uid://b4s8333o8s4cp diff --git a/addons/gut/input_sender.gd b/addons/gut/input_sender.gd new file mode 100644 index 0000000..a94e842 --- /dev/null +++ b/addons/gut/input_sender.gd @@ -0,0 +1,651 @@ +class_name GutInputSender +## The GutInputSender class. It sends input to places. +## +## [br][br] +## GUT Wiki: [url=https://gut.readthedocs.io]https://gut.readthedocs.io[/url][br] +## See [wiki]Mocking-Input[/wiki] for examples. +## [br][br] +## This class can be used to send [code]InputEvent*[/code] events to various +## objects. It also allows you to script out a series of inputs and play +## them back in real time. You could use it to:[br] +## - Verify that jump height depends on how long the jump button is pressed.[br] +## - Double tap a direction performs a dash.[br] +## - Down, Down-Forward, Forward + punch throws a fireball.[br] +## [br][br] +## And much much more. +## [br][br] +## As of 9.3.1 you can use [code skip-lint]GutInputSender[/code] instead of [code]InputSender[/code]. It's the same thing, but [code skip-lint]GutInputSender[/code] is a [code]class_name[/code] so you may have less warnings and auto-complete will work. +## [br][br] +## [b]Warning[/b][br] +## If you move the Godot window to a different monitor while tests are running it can cause input tests to fail. [url=https://github.com/bitwes/Gut/issues/643]This issue[/url] has more details. + + + +# Implemented InputEvent* convenience methods +# InputEventAction +# InputEventKey +# InputEventMouseButton +# InputEventMouseMotion + +# Yet to implement InputEvents +# InputEventJoypadButton +# InputEventJoypadMotion +# InputEventMagnifyGesture +# InputEventMIDI +# InputEventPanGesture +# InputEventScreenDrag +# InputEventScreenTouch + + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +class InputQueueItem: + extends Node + + var events = [] + var time_delay = null + var frame_delay = null + var _waited_frames = 0 + var _is_ready = false + var _delay_started = false + + signal event_ready + + # TODO should this be done in _physics_process instead or should it be + # configurable? + func _physics_process(delta): + if(frame_delay > 0 and _delay_started): + _waited_frames += 1 + if(_waited_frames >= frame_delay): + event_ready.emit() + + func _init(t_delay,f_delay): + time_delay = t_delay + frame_delay = f_delay + _is_ready = time_delay == 0 and frame_delay == 0 + + func _on_time_timeout(): + _is_ready = true + event_ready.emit() + + func _delay_timer(t): + return Engine.get_main_loop().root.get_tree().create_timer(t) + + func is_ready(): + return _is_ready + + func start(): + _delay_started = true + if(time_delay > 0): + var t = _delay_timer(time_delay) + t.connect("timeout",Callable(self,"_on_time_timeout")) + + + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +class MouseDraw: + extends Node2D + + var down_color = Color(1, 1, 1, .25) + var up_color = Color(0, 0, 0, .25) + var line_color = Color(1, 0, 0) + var disabled = true : + get : return disabled + set(val) : + disabled = val + queue_redraw() + + var _draw_at = Vector2(0, 0) + var _b1_down = false + var _b2_down = false + + + func draw_event(event): + if(event is InputEventMouse): + _draw_at = event.position + if(event is InputEventMouseButton): + if(event.button_index == MOUSE_BUTTON_LEFT): + _b1_down = event.pressed + elif(event.button_index == MOUSE_BUTTON_RIGHT): + _b2_down = event.pressed + queue_redraw() + + + func _draw_cicled_cursor(): + var r = 10 + var b1_color = up_color + var b2_color = up_color + + if(_b1_down): + var pos = _draw_at - (Vector2(r * 1.5, 0)) + draw_arc(pos, r / 2, 0, 360, 180, b1_color) + + if(_b2_down): + var pos = _draw_at + (Vector2(r * 1.5, 0)) + draw_arc(pos, r / 2, 0, 360, 180, b2_color) + + draw_arc(_draw_at, r, 0, 360, 360, line_color, 1) + draw_line(_draw_at - Vector2(0, r), _draw_at + Vector2(0, r), line_color) + draw_line(_draw_at - Vector2(r, 0), _draw_at + Vector2(r, 0), line_color) + + + func _draw_square_cursor(): + var r = 10 + var b1_color = up_color + var b2_color = up_color + + if(_b1_down): + b1_color = down_color + + if(_b2_down): + b2_color = down_color + + var blen = r * .75 + # left button rectangle + draw_rect(Rect2(_draw_at - Vector2(blen, blen), Vector2(blen, blen * 2)), b1_color) + # right button rectrangle + draw_rect(Rect2(_draw_at - Vector2(0, blen), Vector2(blen, blen * 2)), b2_color) + # Crosshair + draw_line(_draw_at - Vector2(0, r), _draw_at + Vector2(0, r), line_color) + draw_line(_draw_at - Vector2(r, 0), _draw_at + Vector2(r, 0), line_color) + + + func _draw(): + if(disabled): + return + _draw_square_cursor() + + + + + + + +# ############################################################################## +# +# ############################################################################## +## Local reference to the GutInputFactory static class +const INPUT_WARN = 'If using Input as a reciever it will not respond to *_down events until a *_up event is recieved. Call the appropriate *_up event or use hold_for(...) to automatically release after some duration.' + +var _lgr = GutUtils.get_logger() +var _receivers = [] +var _input_queue = [] +var _next_queue_item = null + +# used by hold_for and echo. +var _last_event = null +# indexed by keycode, each entry contains a boolean value indicating the +# last emitted "pressed" value for that keycode. +var _pressed_keys = {} +var _pressed_actions = {} +var _pressed_mouse_buttons = {} + +var _auto_flush_input = false +var _tree_items_parent = null +var _mouse_draw = null; + +var _default_mouse_position = { + position = Vector2(0, 0), + global_position = Vector2(0, 0) +} + +var _last_mouse_position = { +} + +## Warp mouse when sending InputEventMouse* events +var mouse_warp = false +## Draw mouse position cross hairs. Useful to see where the mouse is at +## when not using [member mouse_warp] +var draw_mouse = true + +## Emitted when all events in the input queue have been sent. +signal idle + + +## Accepts a single optional receiver. +func _init(r=null): + if(r != null): + add_receiver(r) + + _last_mouse_position = _default_mouse_position.duplicate() + _tree_items_parent = Node.new() + Engine.get_main_loop().root.add_child(_tree_items_parent) + + _mouse_draw = MouseDraw.new() + _tree_items_parent.add_child(_mouse_draw) + _mouse_draw.disabled = false + + +func _notification(what): + if(what == NOTIFICATION_PREDELETE): + if(is_instance_valid(_tree_items_parent)): + _tree_items_parent.queue_free() + + +func _add_queue_item(item): + item.connect("event_ready", _on_queue_item_ready.bind(item)) + _next_queue_item = item + _input_queue.append(item) + _tree_items_parent.add_child(item) + if(_input_queue.size() == 1): + item.start() + + +func _handle_pressed_keys(event): + if(event is InputEventKey): + if((event.pressed and !event.echo) and is_key_pressed(event.keycode)): + _lgr.warn(str("InputSender: key_down called for ", event.as_text(), " when that key is already pressed. ", INPUT_WARN)) + _pressed_keys[event.keycode] = event.pressed + elif(event is InputEventAction): + if(event.pressed and is_action_pressed(event.action)): + _lgr.warn(str("InputSender: action_down called for ", event.action, " when that action is already pressed. ", INPUT_WARN)) + _pressed_actions[event.action] = event.pressed + elif(event is InputEventMouseButton): + if(event.pressed and is_mouse_button_pressed(event.button_index)): + _lgr.warn(str("InputSender: mouse_button_down called for ", event.button_index, " when that mouse button is already pressed. ", INPUT_WARN)) + _pressed_mouse_buttons[event.button_index] = event + + +func _handle_mouse_position(event): + if(event is InputEventMouse): + _mouse_draw.disabled = !draw_mouse + _mouse_draw.draw_event(event) + if(mouse_warp): + DisplayServer.warp_mouse(event.position) + + +func _send_event(event): + _handle_mouse_position(event) + _handle_pressed_keys(event) + + for r in _receivers: + if(r == Input): + Input.parse_input_event(event) + if(event is InputEventAction): + if(event.pressed): + Input.action_press(event.action) + else: + Input.action_release(event.action) + if(_auto_flush_input): + Input.flush_buffered_events() + else: + if(r.has_method(&"_input")): + r._input(event) + + if(r.has_signal(&"gui_input")): + r.gui_input.emit(event) + + if(r.has_method(&"_gui_input")): + r._gui_input(event) + + if(r.has_method(&"_unhandled_input")): + r._unhandled_input(event) + + +func _send_or_record_event(event): + _last_event = event + if(_next_queue_item != null): + _next_queue_item.events.append(event) + else: + _send_event(event) + + +func _set_last_mouse_positions(event : InputEventMouse): + _last_mouse_position.position = event.position + _last_mouse_position.global_position = event.global_position + + +func _apply_last_position_and_set_last_position(event, position, global_position): + event.position = GutUtils.nvl(position, _last_mouse_position.position) + event.global_position = GutUtils.nvl( + global_position, _last_mouse_position.global_position) + _set_last_mouse_positions(event) + + +func _new_defaulted_mouse_button_event(position, global_position): + var event = InputEventMouseButton.new() + _apply_last_position_and_set_last_position(event, position, global_position) + return event + + +func _new_defaulted_mouse_motion_event(position, global_position): + var event = InputEventMouseMotion.new() + _apply_last_position_and_set_last_position(event, position, global_position) + for key in _pressed_mouse_buttons: + if(_pressed_mouse_buttons[key].pressed): + event.button_mask += key + return event + + +# ------------------------------ +# Events +# ------------------------------ +func _on_queue_item_ready(item): + for event in item.events: + _send_event(event) + + var done_event = _input_queue.pop_front() + done_event.queue_free() + + if(_input_queue.size() == 0): + _next_queue_item = null + idle.emit() + else: + _input_queue[0].start() + + +# ------------------------------ +# Public +# ------------------------------ + + +## Add an object to receive input events. +func add_receiver(obj): + _receivers.append(obj) + + +## Returns the receivers that have been added. +func get_receivers(): + return _receivers + + +## Returns true if the input queue has items to be processed, false if not. +func is_idle(): + return _input_queue.size() == 0 + +func is_key_pressed(which): + var event = GutInputFactory.key_up(which) + return _pressed_keys.has(event.keycode) and _pressed_keys[event.keycode] + +func is_action_pressed(which): + return _pressed_actions.has(which) and _pressed_actions[which] + +func is_mouse_button_pressed(which): + return _pressed_mouse_buttons.has(which) and _pressed_mouse_buttons[which].pressed + + +## Get the value of [method set_auto_flush_input]. +func get_auto_flush_input(): + return _auto_flush_input + + +## Enable/Disable auto flushing of input. When enabled the [GutInputSender] +## will call [code]Input.flush_buffered_events[/code] after each event is sent. +## See the "use_accumulated_input" section in [wiki]Mocking-Input[/wiki] for more +## information. +func set_auto_flush_input(val): + _auto_flush_input = val + + +## Adds a delay between the last input queue item added and any queue item added +## next. By default this will wait [param t] seconds. You can specify a +## number of frames to wait by passing a string composed of a number and "f". +## For example [code]wait("5f")[/code] will wait 5 frames. +func wait(t): + if(typeof(t) == TYPE_STRING): + var suffix = t.substr(t.length() -1, 1) + var val = t.rstrip('s').rstrip('f').to_float() + + if(suffix.to_lower() == 's'): + wait_secs(val) + elif(suffix.to_lower() == 'f'): + wait_frames(val) + else: + wait_secs(t) + + return self + + +## Clears the input queue and any state such as the last event sent and any +## pressed actions/buttons. Does not clear the list of receivers. +## [br][br] +## This should be done between each test when the [GutInputSender] is a class +## level variable so that state does not leak between tests. +func clear(): + _last_event = null + _next_queue_item = null + + for item in _input_queue: + item.free() + _input_queue.clear() + + _pressed_keys.clear() + _pressed_actions.clear() + _pressed_mouse_buttons.clear() + _last_mouse_position = _default_mouse_position.duplicate() + + +# ------------------------------ +# Event methods +# ------------------------------ + +## Sends a [InputEventKey] event with [code]pressed = false[/code]. [param which] can be a character or a [code]KEY_*[/code] constant. +func key_up(which): + var event = GutInputFactory.key_up(which) + _send_or_record_event(event) + return self + + +## Sends a [InputEventKey] event with [code]pressed = true[/code]. [param which] can be a character or a [code]KEY_*[/code] constant. +func key_down(which): + var event = GutInputFactory.key_down(which) + _send_or_record_event(event) + return self + + +## Sends an echo [InputEventKey] event of the last key event. +func key_echo(): + if(_last_event != null and _last_event is InputEventKey): + var new_key = _last_event.duplicate() + new_key.echo = true + _send_or_record_event(new_key) + return self + + +## Sends a "action up" [InputEventAction] instance. [param which] is the name of the action defined in the Key Map. +func action_up(which, strength=1.0): + var event = GutInputFactory.action_up(which, strength) + _send_or_record_event(event) + return self + + +## Sends a "action down" [InputEventAction] instance. [param which] is the name of the action defined in the Key Map. +func action_down(which, strength=1.0): + var event = GutInputFactory.action_down(which, strength) + _send_or_record_event(event) + return self + + +## Sends a "button down" [InputEventMouseButton] for the left mouse button. +func mouse_left_button_down(position=null, global_position=null): + var event = _new_defaulted_mouse_button_event(position, global_position) + event.pressed = true + event.button_index = MOUSE_BUTTON_LEFT + _send_or_record_event(event) + return self + + +## Sends a "button up" [InputEventMouseButton] for the left mouse button. +func mouse_left_button_up(position=null, global_position=null): + var event = _new_defaulted_mouse_button_event(position, global_position) + event.pressed = false + event.button_index = MOUSE_BUTTON_LEFT + _send_or_record_event(event) + return self + + +## Sends a "double click" [InputEventMouseButton] for the left mouse button. +func mouse_double_click(position=null, global_position=null): + var event = GutInputFactory.mouse_double_click(position, global_position) + event.double_click = true + _send_or_record_event(event) + return self + + +## Sends a "button down" [InputEventMouseButton] for the right mouse button. +func mouse_right_button_down(position=null, global_position=null): + var event = _new_defaulted_mouse_button_event(position, global_position) + event.pressed = true + event.button_index = MOUSE_BUTTON_RIGHT + _send_or_record_event(event) + return self + + +## Sends a "button up" [InputEventMouseButton] for the right mouse button. +func mouse_right_button_up(position=null, global_position=null): + var event = _new_defaulted_mouse_button_event(position, global_position) + event.pressed = false + event.button_index = MOUSE_BUTTON_RIGHT + _send_or_record_event(event) + return self + + +## Sends a [InputEventMouseMotion] to move the mouse the specified positions. +func mouse_motion(position, global_position=null): + var event = _new_defaulted_mouse_motion_event(position, global_position) + _send_or_record_event(event) + return self + + +## Sends a [InputEventMouseMotion] that moves the mouse [param offset] +## from the last [method mouse_motion] or [method mouse_set_position] call. +func mouse_relative_motion(offset, speed=Vector2(0, 0)): + var last_event = _new_defaulted_mouse_motion_event(null, null) + var event = GutInputFactory.mouse_relative_motion(offset, last_event, speed) + _set_last_mouse_positions(event) + _send_or_record_event(event) + return self + + +## Sets the mouse's position. This does not send an event. This position will +## be used for the next call to [method mouse_relative_motion]. +func mouse_set_position(position, global_position=null): + var event = _new_defaulted_mouse_motion_event(position, global_position) + return self + + +## Performs a left click at the given position. +func mouse_left_click_at(where, duration = '5f'): + wait_frames(1) + mouse_left_button_down(where) + hold_for(duration) + wait_frames(10) + return self + + +## Create your own event and use this to send it to all receivers. +func send_event(event): + _send_or_record_event(event) + return self + + +## Releases all [InputEventKey], [InputEventAction], and [InputEventMouseButton] +## events that have passed through this instance. These events could have been +## generated via the various [code]_down[/code] methods or passed to +## [method send_event]. +## [br][br] +## This will send the "release" event ([code]pressed = false[/code]) to all +## receivers. This should be done between each test when using `Input` as a +## receiver. +func release_all(): + for key in _pressed_keys: + if(_pressed_keys[key]): + _send_event(GutInputFactory.key_up(key)) + _pressed_keys.clear() + + for key in _pressed_actions: + if(_pressed_actions[key]): + _send_event(GutInputFactory.action_up(key)) + _pressed_actions.clear() + + for key in _pressed_mouse_buttons: + var event = _pressed_mouse_buttons[key].duplicate() + if(event.pressed): + event.pressed = false + _send_event(event) + _pressed_mouse_buttons.clear() + + return self + +## Same as [method wait] but only accepts a number of frames to wait. +func wait_frames(num_frames): + var item = InputQueueItem.new(0, num_frames) + _add_queue_item(item) + return self + + +## Same as [method wait] but only accepts a number of seconds to wait. +func wait_secs(num_secs): + var item = InputQueueItem.new(num_secs, 0) + _add_queue_item(item) + return self + + +## This is a special [method wait] that will emit the previous input queue item +## with [code]pressed = false[/code] after a delay. If you pass a number then +## it will wait that many seconds. You can also use the `"4f"` format to wait +## a specific number of frames. +## [br][br] +## For example [code]sender.action_down('jump').hold_for("10f")[/code] will +## cause two [InputEventAction] instances to be sent. The "jump-down" event +## from [method action_down] and then a "jump-up" event after 10 frames. +func hold_for(duration): + if(_last_event != null and _last_event.pressed): + var next_event = _last_event.duplicate() + next_event.pressed = false + + wait(duration) + send_event(next_event) + return self + + +## Same as [method hold_for] but specifically holds for a number of physics +## frames. +func hold_frames(duration:int): + return hold_for(str(duration, 'f')) + + +## Same as [method hold_for] but specifically holds for a number of seconds. +func hold_seconds(duration:float): + return hold_for(duration) + + +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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. +# +# ############################################################################## +# Description +# ----------- +# This class sends input to one or more recievers. The receivers' _input, +# _unhandled_input, and _gui_input are called sending InputEvent* events. +# InputEvents can be sent via the helper methods or a custom made InputEvent +# can be sent via send_event(...) +# +# ############################################################################## diff --git a/addons/gut/input_sender.gd.uid b/addons/gut/input_sender.gd.uid new file mode 100644 index 0000000..1ff32fc --- /dev/null +++ b/addons/gut/input_sender.gd.uid @@ -0,0 +1 @@ +uid://da3cy6yko53dk diff --git a/addons/gut/junit_xml_export.gd b/addons/gut/junit_xml_export.gd new file mode 100644 index 0000000..d4812e9 --- /dev/null +++ b/addons/gut/junit_xml_export.gd @@ -0,0 +1,116 @@ +## Creates an export of a test run in the JUnit XML format. +## +## More words needed? + +var _exporter = GutUtils.ResultExporter.new() + +## @ignore should be private I think +func indent(s, ind): + var to_return = ind + s + to_return = to_return.replace("\n", "\n" + ind) + return to_return + +# Wraps content in CDATA section because it may contain special characters +# e.g. str(null) becomes and can break XML parsing. +func wrap_cdata(content): + return "" + +## @ignore should be private I think +func add_attr(name, value): + return str(name, '="', value, '" ') + + +func _export_test_result(test): + var to_return = '' + + # Right now the pending and failure messages won't fit in the message + # attribute because they can span multiple lines and need to be escaped. + if(test.status == 'pending'): + var skip_tag = str("", wrap_cdata(test.pending[0]), "") + to_return += skip_tag + elif(test.status == 'fail'): + var fail_tag = str("", wrap_cdata(test.failing[0]), "") + to_return += fail_tag + + return to_return + + +func _export_tests(script_result, classname): + var to_return = "" + + for key in script_result.keys(): + var test = script_result[key] + var assert_count = test.passing.size() + test.failing.size() + to_return += "float: + var to_return := 0.0 + + for key in script_result.keys(): + var test = script_result[key] + to_return += test.time_taken + + return to_return + + +func _export_scripts(exp_results): + var to_return = "" + for key in exp_results.test_scripts.scripts.keys(): + var s = exp_results.test_scripts.scripts[key] + to_return += " 0): + # Just an FYI, parameter_handler in gut might not be set yet so can't + # use it here for cooler output. + param_text = '' + _output(str('* ', cur_test.name, param_text, "\n")) + cur_test.has_printed_name = true + + +func _output(text, fmt=null): + for key in _printers: + if(_should_print_to_printer(key)): + _printers[key].send(text, fmt) + + +func _log(text, fmt=fmts.none): + _print_test_name() + var indented = _indent_text(text) + _output(indented, fmt) + +# --------------- +# Get Methods +# --------------- +func get_warnings(): + return get_log_entries(types.warn) + + +func get_errors(): + return get_log_entries(types.error) + + +func get_infos(): + return get_log_entries(types.info) + + +func get_debugs(): + return get_log_entries(types.debug) + + +func get_deprecated(): + return get_log_entries(types.deprecated) + + +func get_count(log_type=null): + var count = 0 + if(log_type == null): + for key in _logs: + count += _logs[key].size() + else: + count = _logs[log_type].size() + return count + + +func get_log_entries(log_type): + return _logs[log_type] + + +func get_indent(): + var pad = '' + for i in range(_indent_level): + pad += _indent_string + + return pad + + +# --------------- +# Log methods +# --------------- +func _output_type(type, text): + var td = _type_data[type] + if(!td.enabled): + # if(_logs.has(type)): + # _logs[type].append(text) + return + + _print_test_name() + if(type != types.normal): + if(_logs.has(type)): + _logs[type].append(text) + + var start = str('[', td.disp, ']') + if(text != null and text != ''): + start += ': ' + else: + start += ' ' + var indented_start = _indent_text(start) + var indented_end = _indent_text(text) + indented_end = indented_end.lstrip(_indent_string) + _output(indented_start, td.fmt) + _output(indented_end + "\n") + + +func _output_type_no_indent(type, text): + var td = _type_data[type] + if(!td.enabled): + # if(_logs.has(type)): + # _logs[type].append(text) + return + + _print_test_name() + if(type != types.normal): + if(_logs.has(type)): + _logs[type].append(text) + + var start = str('[', td.disp, ']') + _output(start, td.fmt) + _output(text + "\n") + + +func debug(text): + _output_type(types.debug, text) + + +# supply some text or the name of the deprecated method and the replacement. +func deprecated(text, alt_method=null): + var msg = text + if(alt_method): + msg = str('The method ', text, ' is deprecated, use ', alt_method , ' instead.') + return _output_type(types.deprecated, msg) + + +func error(text): + _output_type(types.error, text) + # Use the _gut one instead of GutUtils.get_error_tracker() for testing + # purposes. This probably means this should have its own reference but + # that seems too difficult now. + if(_gut != null): + _gut.error_tracker.add_gut_error(text) + + +func expected_error(text): + _output_type_no_indent(types.expected_error, text) + + +func failed(text): + _output_type(types.failed, text) + + +func info(text): + _output_type(types.info, text) + + +func orphan(text): + var td = _type_data["orphan"] + if(!td.enabled): + return + _output(_indent_text(text), td.fmt) + _output("\n") + # _output_type(types.orphan, text) + + +func passed(text): + _output_type(types.passed, text) + + +func pending(text): + _output_type(types.pending, text) + + +func risky(text): + _output_type(types.risky, text) + + +func warn(text): + _output_type(types.warn, text) + + +func log(text='', fmt=fmts.none): + if(text == ''): + _output("\n") + else: + _log(text + "\n", fmt) + return null + + +func lograw(text, fmt=fmts.none): + return _output(text, fmt) + + +# Print the test name if we aren't skipping names of tests that pass (basically +# what _less_test_names means)) +func log_test_name(): + # suppress output if we haven't printed the test name yet and + # what to print is the test name. + if(!_less_test_names): + _print_test_name() + +# --------------- +# Misc +# --------------- +func get_gut(): + return _gut + + +func set_gut(gut): + _gut = gut + if(_gut == null): + _printers.gui = null + else: + if(_printers.gui == null): + _printers.gui = GutUtils.Printers.GutGuiPrinter.new() + + +func get_indent_level(): + return _indent_level + + +func set_indent_level(indent_level): + _indent_level = max(_min_indent_level, indent_level) + + +func get_indent_string(): + return _indent_string + + +func set_indent_string(indent_string): + _indent_string = indent_string + + +func clear(): + for key in _logs: + _logs[key].clear() + + +func inc_indent(): + _indent_level += 1 + + +func dec_indent(): + _indent_level = max(_min_indent_level, _indent_level -1) + + +func is_type_enabled(type): + return _type_data[type].enabled + + +func set_type_enabled(type, is_enabled): + _type_data[type].enabled = is_enabled + + +func get_less_test_names(): + return _less_test_names + + +func set_less_test_names(less_test_names): + _less_test_names = less_test_names + + +func disable_printer(name, is_disabled): + if(_printers[name] != null): + _printers[name].set_disabled(is_disabled) + + +func is_printer_disabled(name): + return _printers[name].get_disabled() + + +func disable_formatting(is_disabled): + for key in _printers: + _printers[key].set_format_enabled(!is_disabled) + + +func disable_all_printers(is_disabled): + for p in _printers: + disable_printer(p, is_disabled) + + +func get_printer(printer_key): + return _printers[printer_key] + + +func _yield_text_terminal(text): + var printer = _printers['terminal'] + if(_yield_calls != 0): + printer.clear_line() + printer.back(_last_yield_text.length()) + printer.send(text, fmts.yellow) + + +# Format and printing rules for the "Awaiting" messages. +func wait_msg(text): + if(_type_data.warn.enabled): + self.log(text, fmts.yellow) + + +func get_gui_bbcode(): + return _printers.gui.get_bbcode() + + + +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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. +# +# ############################################################################## +# This class wraps around the various printers and supplies formatting for the +# various message types (error, warning, etc). +# ############################################################################## diff --git a/addons/gut/logger.gd.uid b/addons/gut/logger.gd.uid new file mode 100644 index 0000000..dc0510b --- /dev/null +++ b/addons/gut/logger.gd.uid @@ -0,0 +1 @@ +uid://cqd3rmuu2drug diff --git a/addons/gut/menu_manager.gd.uid b/addons/gut/menu_manager.gd.uid new file mode 100644 index 0000000..5a157a4 --- /dev/null +++ b/addons/gut/menu_manager.gd.uid @@ -0,0 +1 @@ +uid://b3k81q304cji1 diff --git a/addons/gut/method_maker.gd b/addons/gut/method_maker.gd new file mode 100644 index 0000000..559d1ef --- /dev/null +++ b/addons/gut/method_maker.gd @@ -0,0 +1,254 @@ +class CallParameters: + var p_name = null + var default = null + var vararg = false + + func _init(n,d): + p_name = n + default = d + + func get_signature(): + if(vararg): + return "...args: Array" + else: + return str(p_name, "=", default) + + +# ------------------------------------------------------------------------------ +# This class will generate method declaration lines based on method meta +# data. It will create defaults that match the method data. +# +# -------------------- +# function meta data +# -------------------- +# name: +# flags: +# args: [{ +# (class_name:), +# (hint:0), +# (hint_string:), +# (name:), +# (type:4), +# (usage:7) +# }] +# default_args [] + +var _lgr = GutUtils.get_logger() +const PARAM_PREFIX = 'p_' + +# ------------------------------------------------------ +# _supported_defaults +# +# This array contains all the data types that are supported for default values. +# If a value is supported it will contain either an empty string or a prefix +# that should be used when setting the parameter default value. +# For example int, real, bool do not need anything func(p1=1, p2=2.2, p3=false) +# but things like Vectors and Colors do since only the parameters to create a +# new Vector or Color are included in the metadata. +# ------------------------------------------------------ + # TYPE_NIL = 0 — Variable is of type nil (only applied for null). + # TYPE_BOOL = 1 — Variable is of type bool. + # TYPE_INT = 2 — Variable is of type int. + # TYPE_FLOAT = 3 — Variable is of type float/real. + # TYPE_STRING = 4 — Variable is of type String. + # TYPE_VECTOR2 = 5 — Variable is of type Vector2. + # TYPE_RECT2 = 6 — Variable is of type Rect2. + # TYPE_VECTOR3 = 7 — Variable is of type Vector3. + # TYPE_COLOR = 14 — Variable is of type Color. + # TYPE_OBJECT = 17 — Variable is of type Object. + # TYPE_DICTIONARY = 18 — Variable is of type Dictionary. + # TYPE_ARRAY = 19 — Variable is of type Array. + # TYPE_PACKED_VECTOR2_ARRAY = 24 — Variable is of type PackedVector2Array. + # TYPE_TRANSFORM3D = 13 — Variable is of type Transform3D. + # TYPE_TRANSFORM2D = 8 — Variable is of type Transform2D. + # TYPE_RID = 16 — Variable is of type RID. + # TYPE_PACKED_INT32_ARRAY = 21 — Variable is of type PackedInt32Array. + # TYPE_PACKED_FLOAT32_ARRAY = 22 — Variable is of type PackedFloat32Array. + # TYPE_PACKED_STRING_ARRAY = 23 — Variable is of type PackedStringArray. + + +# TYPE_PLANE = 9 — Variable is of type Plane. +# TYPE_QUATERNION = 10 — Variable is of type Quaternion. +# TYPE_AABB = 11 — Variable is of type AABB. +# TYPE_BASIS = 12 — Variable is of type Basis. +# TYPE_NODE_PATH = 15 — Variable is of type NodePath. +# TYPE_PACKED_BYTE_ARRAY = 20 — Variable is of type PackedByteArray. +# TYPE_PACKED_VECTOR3_ARRAY = 25 — Variable is of type PackedVector3Array. +# TYPE_PACKED_COLOR_ARRAY = 26 — Variable is of type PackedColorArray. +# TYPE_MAX = 27 — Marker for end of type constants. +# ------------------------------------------------------ +var _supported_defaults = [] + +func _init(): + for _i in range(TYPE_MAX): + _supported_defaults.append(null) + + # These types do not require a prefix for defaults + _supported_defaults[TYPE_NIL] = '' + _supported_defaults[TYPE_BOOL] = '' + _supported_defaults[TYPE_INT] = '' + _supported_defaults[TYPE_FLOAT] = '' + _supported_defaults[TYPE_OBJECT] = '' + _supported_defaults[TYPE_ARRAY] = '' + _supported_defaults[TYPE_STRING] = '' + _supported_defaults[TYPE_STRING_NAME] = '' + _supported_defaults[TYPE_DICTIONARY] = '' + _supported_defaults[TYPE_PACKED_VECTOR2_ARRAY] = '' + _supported_defaults[TYPE_RID] = '' + + # These require a prefix for whatever default is provided + _supported_defaults[TYPE_VECTOR2] = 'Vector2' + _supported_defaults[TYPE_VECTOR2I] = 'Vector2i' + _supported_defaults[TYPE_RECT2] = 'Rect2' + _supported_defaults[TYPE_RECT2I] = 'Rect2i' + _supported_defaults[TYPE_VECTOR3] = 'Vector3' + _supported_defaults[TYPE_COLOR] = 'Color' + _supported_defaults[TYPE_TRANSFORM2D] = 'Transform2D' + _supported_defaults[TYPE_TRANSFORM3D] = 'Transform3D' + _supported_defaults[TYPE_PACKED_INT32_ARRAY] = 'PackedInt32Array' + _supported_defaults[TYPE_PACKED_FLOAT32_ARRAY] = 'PackedFloat32Array' + _supported_defaults[TYPE_PACKED_STRING_ARRAY] = 'PackedStringArray' + +# ############### +# Private +# ############### +var _func_text = GutUtils.get_file_as_text('res://addons/gut/double_templates/function_template.txt') +var _init_text = GutUtils.get_file_as_text('res://addons/gut/double_templates/init_template.txt') + +func _is_supported_default(type_flag): + return type_flag >= 0 and type_flag < _supported_defaults.size() and _supported_defaults[type_flag] != null + + +func _make_stub_default(method, index): + return str('__gutdbl.default_val("', method, '",', index, ')') + + +func _make_arg_array(method_meta): + var to_return = [] + + var has_unsupported_defaults = false + + for i in range(method_meta.args.size()): + var pname = method_meta.args[i].name + var dflt_text = _make_stub_default(method_meta.name, i) + to_return.append(CallParameters.new(PARAM_PREFIX + pname, dflt_text)) + + if(method_meta.flags & METHOD_FLAG_VARARG): + var cp = CallParameters.new("args", "") + cp.vararg = true + to_return.append(cp) + + return [has_unsupported_defaults, to_return]; + + +# Creates a list of parameters with defaults of null unless a default value is +# found in the metadata. If a default is found in the meta then it is used if +# it is one we know how support. +# +# If a default is found that we don't know how to handle then this method will +# return null. +func _get_arg_text(arg_array): + var text = '' + + for i in range(arg_array.size()): + text += arg_array[i].get_signature() + if(i != arg_array.size() -1): + text += ', ' + + return text + + +# creates a call to the function in meta in the super's class. +func _get_super_call_text(method_name, args): + var params = '' + for i in range(args.size()): + params += args[i].p_name + if(i != args.size() -1): + params += ', ' + + return str('await super(', params, ')') + + +func _get_spy_call_parameters_text(args): + var called_with = 'null' + + if(args.size() > 0): + called_with = '[' + for i in range(args.size()): + called_with += args[i].p_name + if(i < args.size() - 1): + called_with += ', ' + called_with += ']' + + return called_with + + +# ############### +# Public +# ############### + +func _get_init_text(meta, args, method_params, param_array): + var text = null + + var decleration = str('func ', meta.name, '(', method_params, ')') + var super_params = '' + if(args.size() > 0): + for i in range(args.size()): + super_params += args[i].p_name + if(i != args.size() -1): + super_params += ', ' + + text = _init_text.format({ + "func_decleration":decleration, + "super_params":super_params, + "param_array":param_array, + "method_name":meta.name, + }) + + return text + + +# Creates a delceration for a function based off of function metadata. All +# types whose defaults are supported will have their values. If a datatype +# is not supported and the parameter has a default, a warning message will be +# printed and the declaration will return null. +func get_function_text(meta, override_size=null): + var method_params = '' + var text = null + var result = _make_arg_array(meta) + var has_unsupported = result[0] + var args = result[1] + + var param_array = _get_spy_call_parameters_text(args) + if(has_unsupported): + # This will cause a runtime error. This is the most convenient way to + # to stop running before the error gets more obscure. _make_arg_array + # generates a gut error when unsupported defaults are found. + method_params = null + else: + method_params = _get_arg_text(args); + + if(param_array == 'null'): + param_array = '[]' + + if(method_params != null): + if(meta.name == '_init'): + text = _get_init_text(meta, args, method_params, param_array) + else: + var decleration = str('func ', meta.name, '(', method_params, '):') + text = _func_text.format({ + "func_decleration":decleration, + "method_name":meta.name, + "param_array":param_array, + "super_call":_get_super_call_text(meta.name, args), + }) + + return text + + +func get_logger(): + return _lgr + + +func set_logger(logger): + _lgr = logger diff --git a/addons/gut/method_maker.gd.uid b/addons/gut/method_maker.gd.uid new file mode 100644 index 0000000..e57102f --- /dev/null +++ b/addons/gut/method_maker.gd.uid @@ -0,0 +1 @@ +uid://c6fxaf7bsbrp7 diff --git a/addons/gut/one_to_many.gd b/addons/gut/one_to_many.gd new file mode 100644 index 0000000..bd59d78 --- /dev/null +++ b/addons/gut/one_to_many.gd @@ -0,0 +1,43 @@ +# ------------------------------------------------------------------------------ +# This datastructure represents a simple one-to-many relationship. It manages +# a dictionary of value/array pairs. It ignores duplicates of both the "one" +# and the "many". +# ------------------------------------------------------------------------------ +var items = {} + +# return the size of items or the size of an element in items if "one" was +# specified. +func size(one=null): + var to_return = 0 + if(one == null): + to_return = items.size() + elif(items.has(one)): + to_return = items[one].size() + return to_return + + +# Add an element to "one" if it does not already exist +func add(one, many_item): + if(items.has(one)): + if(!items[one].has(many_item)): + items[one].append(many_item) + else: + items[one] = [many_item] + + +func clear(): + items.clear() + + +func has(one, many_item): + var to_return = false + if(items.has(one)): + to_return = items[one].has(many_item) + return to_return + + +func to_s(): + var to_return = '' + for key in items: + to_return += str(key, ": ", items[key], "\n") + return to_return diff --git a/addons/gut/one_to_many.gd.uid b/addons/gut/one_to_many.gd.uid new file mode 100644 index 0000000..62745fe --- /dev/null +++ b/addons/gut/one_to_many.gd.uid @@ -0,0 +1 @@ +uid://cy3q0wsdgq515 diff --git a/addons/gut/orphan_counter.gd b/addons/gut/orphan_counter.gd new file mode 100644 index 0000000..313f8b6 --- /dev/null +++ b/addons/gut/orphan_counter.gd @@ -0,0 +1,221 @@ +# ------------------------------------------------------------------------------ +# It keeps track of the orphans...so this is best name it could ever have. +# ------------------------------------------------------------------------------ +class Orphanage: + const UNGROUPED = "Outside Tests" + const SUBGROUP_SEP = '->' + + var orphan_ids = {} + var oprhans_by_group = {} + var strutils = GutUtils.Strutils.new() + + # wrapper for stubbing + func _get_system_orphan_node_ids(): + return Node.get_orphan_node_ids() + + + func _make_group_key(group=null, subgroup=null): + var to_return = UNGROUPED + if(group != null): + to_return = group + + if(subgroup == null): + to_return += str(SUBGROUP_SEP, UNGROUPED) + else: + to_return += str(SUBGROUP_SEP, subgroup) + + return to_return + + + func _add_orphan_by_group(id, group, subgroup): + var key = _make_group_key(group, subgroup) + if(oprhans_by_group.has(key)): + oprhans_by_group[key].append(id) + else: + oprhans_by_group[key] = [id] + + + func process_orphans(group=null, subgroup=null): + var new_orphans = [] + for orphan_id in _get_system_orphan_node_ids(): + if(!orphan_ids.has(orphan_id)): + new_orphans.append(orphan_id) + orphan_ids[orphan_id] = { + "group":GutUtils.nvl(group, UNGROUPED), + "subgroup":GutUtils.nvl(subgroup, UNGROUPED), + "instance":instance_from_id(orphan_id) + } + _add_orphan_by_group(orphan_id, group, subgroup) + + return new_orphans + + + func get_orphan_ids(group=null, subgroup=null): + var key = _make_group_key(group, subgroup) + return oprhans_by_group.get(key, []) + + + # Given the likely size, this was way easier than making a dictionary + # of dictionaries of arrays. + func get_all_group_orphans(group): + var to_return = [] + for key in oprhans_by_group: + if(key == group or key.begins_with(str(group, SUBGROUP_SEP))): + to_return.append_array(oprhans_by_group[key]) + return to_return + + + # clears out anything that is not still an orphan. + func clean(): + oprhans_by_group.clear() + for key in orphan_ids.keys(): + var inst = orphan_ids[key].instance + if(!is_instance_valid(inst) or inst.get_parent() != null and not orphan_ids.has(inst.get_parent().get_instance_id())): + orphan_ids.erase(key) + else: + _add_orphan_by_group(key, orphan_ids[key].group, orphan_ids[key].subgroup) + + + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +var _strutils = GutStringUtils.new() + +var orphanage : Orphanage = Orphanage.new() +var logger = GutUtils.get_logger() +var autofree = GutUtils.AutoFree.new() + + +func _count_all_children(instance): + var count = instance.get_child_count() + for child in instance.get_children(): + count += _count_all_children(child) + return count + + +func get_orphan_list_text(orphan_ids): + var text = "" + for id in orphan_ids: + var kid_count_text = '' + var inst = orphanage.orphan_ids[id].instance + if(is_instance_valid(inst) and inst.get_parent() == null): + var kid_count = _count_all_children(inst) + if(kid_count != 0): + kid_count_text = str(' + ', kid_count) + + var autofree_text = '' + if(autofree.has_instance_id(id)): + autofree_text = (" (autofree)") + + if(text != ''): + text += "\n" + text += str('* [', _strutils.type2str(inst), ']', kid_count_text, autofree_text) + + return text + + +func orphan_count() -> int: + return int(Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)) + + +func record_orphans(group, subgroup = null): + return orphanage.process_orphans(group, subgroup) + + +func convert_instance_ids_to_valid_instances(instance_ids): + var to_return = [] + for entry in instance_ids: + if(is_instance_id_valid(entry)): + to_return.append(instance_from_id(entry)) + return to_return + + +func end_script(script_path, should_log): + record_orphans(script_path) + var orphans = orphanage.get_all_group_orphans(script_path) + if(orphans.size() > 0 and should_log): + logger.orphan(str(orphans.size(), ' orphans')) + + +func end_test(script_path, test_name, should_log = true): + record_orphans(script_path, test_name) + orphanage.clean() + # Must get all the orphans and not just the results of record_orphans + # because record_orphans may have been called for this group/subgroup + # already. + var orphans = get_orphan_ids(script_path, test_name) + if(orphans.size() > 0 and should_log): + logger.orphan(str(orphans.size(), ' Orphans')) + logger.inc_indent() + logger.orphan(get_orphan_list_text(orphans)) + logger.dec_indent() + + +func get_orphan_ids(group=null, subgroup=null): + var ids = [] + if(group == null): + ids = orphanage.orphan_ids.keys() + elif(subgroup == null): + ids = orphanage.get_all_group_orphans(group) + else: + ids = orphanage.get_orphan_ids(group, subgroup) + + return ids + + +func get_count() -> int: + return orphan_count() + + +func log_all(): + var last_script = '' + var last_test = '' + + for id in orphanage.orphan_ids: + var entry = orphanage.orphan_ids[id] + + if(last_script != entry.group): + last_script = entry.group + last_test = '' + logger.log(entry.group) + + if(last_test != entry.subgroup): + logger.inc_indent() + logger.log(str('- ', entry.subgroup)) + last_test = entry.subgroup + logger.inc_indent() + var orphan_ids = orphanage.get_orphan_ids(last_script, last_test) + logger.orphan(get_orphan_list_text(orphan_ids)) + logger.dec_indent() + logger.dec_indent() + + +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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/gut/orphan_counter.gd.uid b/addons/gut/orphan_counter.gd.uid new file mode 100644 index 0000000..e67f4a2 --- /dev/null +++ b/addons/gut/orphan_counter.gd.uid @@ -0,0 +1 @@ +uid://cgoysoitlbudy diff --git a/addons/gut/parameter_factory.gd b/addons/gut/parameter_factory.gd new file mode 100644 index 0000000..906545a --- /dev/null +++ b/addons/gut/parameter_factory.gd @@ -0,0 +1,91 @@ +## Creates parameter structures for parameterized tests. +## +## This is a static class accessible in a [GutTest] script through +## [member GutTest.ParameterFactory]. It contains methods for constructing parameters to be +## used in parameterized tests. It currently only has one, if you have anyu +## ideas for more, make an issue. More of them would be great since I prematurely +## decided to make this static class and it has such a long name. I'd feel a lot +## better about it if there was more in here. +## [br] +## Additional Helper Ideas?[br] +## [li]File. IDK what it would look like. csv maybe.[/li] +## [li]Random values within a range?[/li] +## [li]All int values in a range or add an optioanal step.[/li] + + + + +## Creates an array of dictionaries. It pairs up the names array with each set +## of values in values. If more names than values are specified then the missing +## values will be filled with nulls. If more values than names are specified +## those values will be ignored. +## +## Example: +##[codeblock] +## create_named_parameters(['a', 'b'], [[1, 2], ['one', 'two']]) returns +## [{a:1, b:2}, {a:'one', b:'two'}] +##[/codeblock] +## [br] +## This allows you to increase readability of your parameterized tests: +## [br] +##[codeblock] +## var params = create_named_parameters(['a', 'b'], [[1, 2], ['one', 'two']]) +## func test_foo(p = use_parameters(params)): +## assert_eq(p.a, p.b) +##[/codeblock] +## [br] +## Parameters:[br] +##[li]names: an array of names to be used as keys in the dictionaries[/li] +##[li]values: an array of arrays of values.[/li] +static func named_parameters(names, values): + var named = [] + for i in range(values.size()): + var entry = {} + + var parray = values[i] + if(typeof(parray) != TYPE_ARRAY): + parray = [values[i]] + + for j in range(names.size()): + if(j >= parray.size()): + entry[names[j]] = null + else: + entry[names[j]] = parray[j] + named.append(entry) + + return named + + + +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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. +# +# ############################################################################## +# This is the home for all parameter creation helpers. These functions should +# all return an array of values to be used as parameters for parameterized +# tests. +# ############################################################################## \ No newline at end of file diff --git a/addons/gut/parameter_factory.gd.uid b/addons/gut/parameter_factory.gd.uid new file mode 100644 index 0000000..c02e808 --- /dev/null +++ b/addons/gut/parameter_factory.gd.uid @@ -0,0 +1 @@ +uid://c0e08oi55x8qj diff --git a/addons/gut/parameter_handler.gd b/addons/gut/parameter_handler.gd new file mode 100644 index 0000000..794d693 --- /dev/null +++ b/addons/gut/parameter_handler.gd @@ -0,0 +1,36 @@ +var _params = null +var _call_count = 0 +var _logger = null + +func _init(params=null): + _params = params + _logger = GutUtils.get_logger() + if(typeof(_params) != TYPE_ARRAY): + _logger.error('You must pass an array to parameter_handler constructor.') + _params = null + + +func next_parameters(): + _call_count += 1 + return _params[_call_count -1] + +func get_current_parameters(): + return _params[_call_count] + +func is_done(): + var done = true + if(_params != null): + done = _call_count == _params.size() + return done + +func get_logger(): + return _logger + +func set_logger(logger): + _logger = logger + +func get_call_count(): + return _call_count + +func get_parameter_count(): + return _params.size() diff --git a/addons/gut/parameter_handler.gd.uid b/addons/gut/parameter_handler.gd.uid new file mode 100644 index 0000000..355ad4f --- /dev/null +++ b/addons/gut/parameter_handler.gd.uid @@ -0,0 +1 @@ +uid://ba87ra5ep18wa diff --git a/addons/gut/plugin.cfg b/addons/gut/plugin.cfg new file mode 100644 index 0000000..46d97f2 --- /dev/null +++ b/addons/gut/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Gut" +description="Unit Testing tool for Godot." +author="Butch Wesley" +version="9.5.0" +script="gut_plugin.gd" diff --git a/addons/gut/printers.gd b/addons/gut/printers.gd new file mode 100644 index 0000000..1b78b4f --- /dev/null +++ b/addons/gut/printers.gd @@ -0,0 +1,184 @@ +# ------------------------------------------------------------------------------ +# Interface and some basic functionality for all printers. +# ------------------------------------------------------------------------------ +class Printer: + var _format_enabled = true + var _disabled = false + var _printer_name = 'NOT SET' + var _show_name = false # used for debugging, set manually + + func get_format_enabled(): + return _format_enabled + + func set_format_enabled(format_enabled): + _format_enabled = format_enabled + + func send(text, fmt=null): + if(_disabled): + return + + var formatted = text + if(fmt != null and _format_enabled): + formatted = format_text(text, fmt) + + if(_show_name): + formatted = str('(', _printer_name, ')') + formatted + + _output(formatted) + + func get_disabled(): + return _disabled + + func set_disabled(disabled): + _disabled = disabled + + # -------------------- + # Virtual Methods (some have some default behavior) + # -------------------- + func _output(text): + pass + + func format_text(text, fmt): + return text + +# ------------------------------------------------------------------------------ +# Responsible for sending text to a GUT gui. +# ------------------------------------------------------------------------------ +class GutGuiPrinter: + extends Printer + var _textbox = null + + var _colors = { + red = Color.RED, + yellow = Color.YELLOW, + green = Color.GREEN, + blue = Color.BLUE + } + + func _init(): + _printer_name = 'gui' + + func _wrap_with_tag(text, tag): + return str('[', tag, ']', text, '[/', tag, ']') + + func _color_text(text, c_word): + return '[color=' + c_word + ']' + text + '[/color]' + + # Remember, we have to use push and pop because the output from the tests + # can contain [] in it which can mess up the formatting. There is no way + # as of 3.4 that you can get the bbcode out of RTL when using push and pop. + # + # The only way we could get around this is by adding in non-printable + # whitespace after each "[" that is in the text. Then we could maybe do + # this another way and still be able to get the bbcode out, or generate it + # at the same time in a buffer (like we tried that one time). + # + # Since RTL doesn't have good search and selection methods, and those are + # really handy in the editor, it isn't worth making bbcode that can be used + # there as well. + # + # You'll try to get it so the colors can be the same in the editor as they + # are in the output. Good luck, and I hope I typed enough to not go too + # far that rabbit hole before finding out it's not worth it. + func format_text(text, fmt): + if(_textbox == null): + return + + if(fmt == 'bold'): + _textbox.push_bold() + elif(fmt == 'underline'): + _textbox.push_underline() + elif(_colors.has(fmt)): + _textbox.push_color(_colors[fmt]) + else: + # just pushing something to pop. + _textbox.push_normal() + + _textbox.add_text(text) + _textbox.pop() + + return '' + + func _output(text): + if(_textbox == null): + return + + _textbox.add_text(text) + + func get_textbox(): + return _textbox + + func set_textbox(textbox): + _textbox = textbox + + # This can be very very slow when the box has a lot of text. + func clear_line(): + _textbox.remove_line(_textbox.get_line_count() - 1) + _textbox.queue_redraw() + + func get_bbcode(): + return _textbox.text + + func get_disabled(): + return _disabled and _textbox != null + +# ------------------------------------------------------------------------------ +# This AND TerminalPrinter should not be enabled at the same time since it will +# result in duplicate output. printraw does not print to the console so i had +# to make another one. +# ------------------------------------------------------------------------------ +class ConsolePrinter: + extends Printer + var _buffer = '' + + func _init(): + _printer_name = 'console' + + # suppresses output until it encounters a newline to keep things + # inline as much as possible. + func _output(text): + if(text.ends_with("\n")): + print(_buffer + text.left(text.length() -1)) + _buffer = '' + else: + _buffer += text + +# ------------------------------------------------------------------------------ +# Prints text to terminal, formats some words. +# ------------------------------------------------------------------------------ +class TerminalPrinter: + extends Printer + + var escape = PackedByteArray([0x1b]).get_string_from_ascii() + var cmd_colors = { + red = escape + '[31m', + yellow = escape + '[33m', + green = escape + '[32m', + blue = escape + '[34m', + + underline = escape + '[4m', + bold = escape + '[1m', + + default = escape + '[0m', + + clear_line = escape + '[2K' + } + + func _init(): + _printer_name = 'terminal' + + func _output(text): + # Note, printraw does not print to the console. + printraw(text) + + func format_text(text, fmt): + return cmd_colors[fmt] + text + cmd_colors.default + + func clear_line(): + send(cmd_colors.clear_line) + + func back(n): + send(escape + str('[', n, 'D')) + + func forward(n): + send(escape + str('[', n, 'C')) diff --git a/addons/gut/printers.gd.uid b/addons/gut/printers.gd.uid new file mode 100644 index 0000000..9880ad2 --- /dev/null +++ b/addons/gut/printers.gd.uid @@ -0,0 +1 @@ +uid://nijvqplhkwjc diff --git a/addons/gut/result_exporter.gd b/addons/gut/result_exporter.gd new file mode 100644 index 0000000..ee5a5ae --- /dev/null +++ b/addons/gut/result_exporter.gd @@ -0,0 +1,125 @@ +# ------------------------------------------------------------------------------ +# Creates a structure that contains all the data about the results of running +# tests. This was created to make an intermediate step organizing the result +# of a run and exporting it in a specific format. This can also serve as a +# unofficial GUT export format. +# ------------------------------------------------------------------------------ +var json = JSON.new() +var strutils = GutStringUtils.new() + +func _export_tests(gut, collected_script): + var to_return = {} + var tests = collected_script.tests + for test in tests: + if(test.get_status_text() != GutUtils.TEST_STATUSES.NOT_RUN): + var orphans = gut.get_orphan_counter().get_orphan_ids( + collected_script.get_filename_and_inner(), + test.name) + var orphan_node_strings = [] + for o in orphans: + if(is_instance_id_valid(o)): + orphan_node_strings.append(strutils.type2str(instance_from_id(o))) + + to_return[test.name] = { + "status":test.get_status_text(), + "passing":test.pass_texts, + "failing":test.fail_texts, + "pending":test.pending_texts, + "orphan_count":orphan_node_strings.size(), + "orphans":orphan_node_strings, + "time_taken": test.time_taken + } + + return to_return + +# TODO +# errors +func _export_scripts(gut): + var collector = gut.get_test_collector() + if(collector == null): + return {} + + var scripts = {} + + for s in collector.scripts: + var test_data = _export_tests(gut, s) + scripts[s.get_full_name()] = { + 'props':{ + "tests":test_data.keys().size(), + "pending":s.get_pending_count(), + "failures":s.get_fail_count(), + "skipped":s.was_skipped, + }, + "tests":test_data + } + return scripts + +func _make_results_dict(): + var result = { + 'test_scripts':{ + "props":{ + "pending":0, + "failures":0, + "passing":0, + "tests":0, + "time":0, + "orphans":0, + "errors":0, + "warnings":0, + "risky":0 + }, + "scripts":[] + } + } + return result + + +func get_results_dictionary(gut, include_scripts=true): + var scripts = [] + + if(include_scripts): + scripts = _export_scripts(gut) + + var result = _make_results_dict() + + var totals = gut.get_summary().get_totals() + + var props = result.test_scripts.props + props.pending = totals.pending + props.failures = totals.failing_tests + props.passing = totals.passing_tests + props.tests = totals.tests + props.errors = gut.logger.get_errors().size() + props.warnings = gut.logger.get_warnings().size() + props.time = gut.get_elapsed_time() + props.orphans = gut.get_orphan_counter().get_count() + props.risky = totals.risky + + result.test_scripts.scripts = scripts + + return result + + +func write_json_file(gut, path): + var dict = get_results_dictionary(gut) + var json_text = JSON.stringify(dict, ' ') + + var f_result = GutUtils.write_file(path, json_text) + if(f_result != OK): + var msg = str("Error: ", f_result, ". Could not create export file ", path) + GutUtils.get_logger().error(msg) + + return f_result + + + +func write_summary_file(gut, path): + var dict = get_results_dictionary(gut, false) + var json_text = JSON.stringify(dict, ' ') + + var f_result = GutUtils.write_file(path, json_text) + if(f_result != OK): + var msg = str("Error: ", f_result, ". Could not create export file ", path) + GutUtils.get_logger().error(msg) + + return f_result diff --git a/addons/gut/result_exporter.gd.uid b/addons/gut/result_exporter.gd.uid new file mode 100644 index 0000000..7044832 --- /dev/null +++ b/addons/gut/result_exporter.gd.uid @@ -0,0 +1 @@ +uid://bbto5glvw8efv diff --git a/addons/gut/script_parser.gd b/addons/gut/script_parser.gd new file mode 100644 index 0000000..396e26a --- /dev/null +++ b/addons/gut/script_parser.gd @@ -0,0 +1,315 @@ +# These methods didn't have flags that would exclude them from being used +# in a double and they appear to break things if they are included. +const BLACKLIST = [ + 'get_script', + 'has_method', +] + + +# ------------------------------------------------------------------------------ +# Combins the meta for the method with additional information. +# * flag for whether the method is local +# * adds a 'default' property to all parameters that can be easily checked per +# parameter +# ------------------------------------------------------------------------------ +class ParsedMethod: + const NO_DEFAULT = '__no__default__' + + var _meta = {} + var meta = _meta : + get: return _meta + set(val): return; + + var is_local = false + var _parameters = [] + + func _init(metadata): + _meta = metadata + var start_default = _meta.args.size() - _meta.default_args.size() + for i in range(_meta.args.size()): + var arg = _meta.args[i] + # Add a "default" property to the metadata so we don't have to do + # weird default paramter position math again. + if(i >= start_default): + arg['default'] = _meta.default_args[start_default - i] + else: + arg['default'] = NO_DEFAULT + _parameters.append(arg) + + + func is_eligible_for_doubling(): + var has_bad_flag = _meta.flags & \ + (METHOD_FLAG_OBJECT_CORE | METHOD_FLAG_VIRTUAL | METHOD_FLAG_STATIC) + return !has_bad_flag and BLACKLIST.find(_meta.name) == -1 + + + func is_accessor(): + return _meta.name.begins_with('@') and \ + (_meta.name.ends_with('_getter') or _meta.name.ends_with('_setter')) + + + func to_s(): + var s = _meta.name + "(" + + for i in range(_meta.args.size()): + var arg = _meta.args[i] + if(str(arg.default) != NO_DEFAULT): + var val = str(arg.default) + if(val == ''): + val = '""' + s += str(arg.name, ' = ', val) + else: + s += str(arg.name) + + if(i != _meta.args.size() -1): + s += ', ' + + s += ")" + return s + + + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +class ParsedScript: + # All methods indexed by name. + var _methods_by_name = {} + + var _script_path = null + var script_path = _script_path : + get: return _script_path + set(val): return; + + var _subpath = null + var subpath = null : + get: return _subpath + set(val): return; + + var _resource = null + var resource = null : + get: return _resource + set(val): return; + + + var _is_native = false + var is_native = _is_native: + get: return _is_native + set(val): return; + + var _native_methods = {} + var _native_class_name = "" + + + + func _init(script_or_inst, inner_class=null): + var to_load = script_or_inst + + if(GutUtils.is_native_class(to_load)): + _resource = to_load + _is_native = true + var inst = to_load.new() + _native_class_name = inst.get_class() + _native_methods = inst.get_method_list() + if(!inst is RefCounted): + inst.free() + else: + if(!script_or_inst is Resource): + to_load = load(script_or_inst.get_script().get_path()) + + _script_path = to_load.resource_path + if(inner_class != null): + _subpath = _find_subpath(to_load, inner_class) + + if(inner_class == null): + _resource = to_load + else: + _resource = inner_class + to_load = inner_class + + _parse_methods(to_load) + + + func _print_flags(meta): + print(str(meta.name, ':').rpad(30), str(meta.flags).rpad(4), ' = ', GutUtils.dec2bistr(meta.flags, 10)) + + + func _get_native_methods(base_type): + var to_return = [] + if(base_type != null): + var source = str('extends ', base_type) + var inst = GutUtils.create_script_from_source(source).new() + to_return = inst.get_method_list() + if(! inst is RefCounted): + inst.free() + return to_return + + + func _parse_methods(thing): + var methods = [] + if(is_native): + methods = _native_methods.duplicate() + else: + var base_type = thing.get_instance_base_type() + methods = _get_native_methods(base_type) + + for m in methods: + var parsed = ParsedMethod.new(m) + _methods_by_name[m.name] = parsed + # _init must always be included so that we can initialize + # double_tools + if(m.name == '_init'): + parsed.is_local = true + + + # This loop will overwrite all entries in _methods_by_name with the local + # method object so there is only ever one listing for a function with + # the right "is_local" flag. + if(!is_native): + methods = thing.get_script_method_list() + for m in methods: + var parsed_method = ParsedMethod.new(m) + parsed_method.is_local = true + _methods_by_name[m.name] = parsed_method + + + func _find_subpath(parent_script, inner): + var const_map = parent_script.get_script_constant_map() + var consts = const_map.keys() + var const_idx = 0 + var found = false + var to_return = null + + while(const_idx < consts.size() and !found): + var key = consts[const_idx] + var const_val = const_map[key] + if(typeof(const_val) == TYPE_OBJECT): + if(const_val == inner): + found = true + to_return = key + else: + to_return = _find_subpath(const_val, inner) + if(to_return != null): + to_return = str(key, '.', to_return) + found = true + + const_idx += 1 + + return to_return + + + func get_method(name): + return _methods_by_name[name] + + + func get_super_method(name): + var to_return = get_method(name) + if(to_return.is_local): + to_return = null + + return to_return + + func get_local_method(name): + var to_return = get_method(name) + if(!to_return.is_local): + to_return = null + + return to_return + + + func get_sorted_method_names(): + var keys = _methods_by_name.keys() + keys.sort() + return keys + + + func get_local_method_names(): + var names = [] + for method in _methods_by_name: + if(_methods_by_name[method].is_local): + names.append(method) + + return names + + + func get_super_method_names(): + var names = [] + for method in _methods_by_name: + if(!_methods_by_name[method].is_local): + names.append(method) + + return names + + + func get_local_methods(): + var to_return = [] + for key in _methods_by_name: + var method = _methods_by_name[key] + if(method.is_local): + to_return.append(method) + return to_return + + + func get_super_methods(): + var to_return = [] + for key in _methods_by_name: + var method = _methods_by_name[key] + if(!method.is_local): + to_return.append(method) + return to_return + + + func get_extends_text(): + var text = null + if(is_native): + text = str("extends ", _native_class_name) + else: + text = str("extends '", _script_path, "'") + if(_subpath != null): + text += '.' + _subpath + return text + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +var scripts = {} + +func _get_instance_id(thing): + var inst_id = null + + if(GutUtils.is_native_class(thing)): + var id_str = str(thing).replace("<", '').replace(">", '').split('#')[1] + inst_id = id_str.to_int() + elif(typeof(thing) == TYPE_STRING): + if(FileAccess.file_exists(thing)): + inst_id = load(thing).get_instance_id() + else: + inst_id = thing.get_instance_id() + + return inst_id + + +func parse(thing, inner_thing=null): + var key = -1 + if(inner_thing == null): + key = _get_instance_id(thing) + else: + key = _get_instance_id(inner_thing) + + var parsed = null + + if(key != null): + if(scripts.has(key)): + parsed = scripts[key] + else: + var obj = instance_from_id(_get_instance_id(thing)) + var inner = null + if(inner_thing != null): + inner = instance_from_id(_get_instance_id(inner_thing)) + + if(obj is Resource or GutUtils.is_native_class(obj)): + parsed = ParsedScript.new(obj, inner) + scripts[key] = parsed + + return parsed + diff --git a/addons/gut/script_parser.gd.uid b/addons/gut/script_parser.gd.uid new file mode 100644 index 0000000..ab60633 --- /dev/null +++ b/addons/gut/script_parser.gd.uid @@ -0,0 +1 @@ +uid://c4k82nmegjoec diff --git a/addons/gut/signal_watcher.gd b/addons/gut/signal_watcher.gd new file mode 100644 index 0000000..0ffc1f6 --- /dev/null +++ b/addons/gut/signal_watcher.gd @@ -0,0 +1,218 @@ +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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. +# +# ############################################################################## + +# Some arbitrary string that should never show up by accident. If it does, then +# shame on you. +const ARG_NOT_SET = '_*_argument_*_is_*_not_set_*_' + +# This hash holds the objects that are being watched, the signals that are being +# watched, and an array of arrays that contains arguments that were passed +# each time the signal was emitted. +# +# For example: +# _watched_signals => { +# ref1 => { +# 'signal1' => [[], [], []], +# 'signal2' => [[p1, p2]], +# 'signal3' => [[p1]] +# }, +# ref2 => { +# 'some_signal' => [], +# 'other_signal' => [[p1, p2, p3], [p1, p2, p3], [p1, p2, p3]] +# } +# } +# +# In this sample: +# - signal1 on the ref1 object was emitted 3 times and each time, zero +# parameters were passed. +# - signal3 on ref1 was emitted once and passed a single parameter +# - some_signal on ref2 was never emitted. +# - other_signal on ref2 was emitted 3 times, each time with 3 parameters. +var _watched_signals = {} +var _lgr = GutUtils.get_logger() + +func _add_watched_signal(obj, name): + # SHORTCIRCUIT - ignore dupes + if(_watched_signals.has(obj) and _watched_signals[obj].has(name)): + return + + if(!_watched_signals.has(obj)): + _watched_signals[obj] = {name:[]} + else: + _watched_signals[obj][name] = [] + obj.connect(name,Callable(self,'_on_watched_signal').bind(obj,name)) + +# This handles all the signals that are watched. It supports up to 9 parameters +# which could be emitted by the signal and the two parameters used when it is +# connected via watch_signal. I chose 9 since you can only specify up to 9 +# parameters when dynamically calling a method via call (per the Godot +# documentation, i.e. some_object.call('some_method', 1, 2, 3...)). +# +# Based on the documentation of emit_signal, it appears you can only pass up +# to 4 parameters when firing a signal. I haven't verified this, but this should +# future proof this some if the value ever grows. +func _on_watched_signal(arg1=ARG_NOT_SET, arg2=ARG_NOT_SET, arg3=ARG_NOT_SET, \ + arg4=ARG_NOT_SET, arg5=ARG_NOT_SET, arg6=ARG_NOT_SET, \ + arg7=ARG_NOT_SET, arg8=ARG_NOT_SET, arg9=ARG_NOT_SET, \ + arg10=ARG_NOT_SET, arg11=ARG_NOT_SET): + var args = [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11] + + # strip off any unused vars. + var idx = args.size() -1 + while(str(args[idx]) == ARG_NOT_SET): + args.remove_at(idx) + idx -= 1 + + # retrieve object and signal name from the array and remove_at them. These + # will always be at the end since they are added when the connect happens. + var signal_name = args[args.size() -1] + args.pop_back() + var object = args[args.size() -1] + args.pop_back() + + if(_watched_signals.has(object)): + _watched_signals[object][signal_name].append(args) + else: + _lgr.error(str("signal_watcher._on_watched_signal: Got signal for unwatched object: ", object, '::', signal_name)) + +# This parameter stuff should go into test.gd not here. This thing works +# just fine the way it is. +func _obj_name_pair(obj_or_signal, signal_name=null): + var to_return = { + 'object' : obj_or_signal, + 'signal_name' : signal_name + } + if(obj_or_signal is Signal): + to_return.object = obj_or_signal.get_object() + to_return.signal_name = obj_or_signal.get_name() + + return to_return + + +func does_object_have_signal(object, signal_name): + var signals = object.get_signal_list() + for i in range(signals.size()): + if(signals[i]['name'] == signal_name): + return true + return false + +func watch_signals(object): + var signals = object.get_signal_list() + for i in range(signals.size()): + _add_watched_signal(object, signals[i]['name']) + +func watch_signal(object, signal_name): + var did = false + if(does_object_have_signal(object, signal_name)): + _add_watched_signal(object, signal_name) + did = true + else: + GutUtils.get_logger().warn(str(object, ' does not have signal ', signal_name)) + return did + +func get_emit_count(object, signal_name): + var to_return = -1 + if(is_watching(object, signal_name)): + to_return = _watched_signals[object][signal_name].size() + return to_return + +func did_emit(object, signal_name=null): + var vals = _obj_name_pair(object, signal_name) + var did = false + if(is_watching(vals.object, vals.signal_name)): + did = get_emit_count(vals.object, vals.signal_name) != 0 + return did + +func print_object_signals(object): + var list = object.get_signal_list() + for i in range(list.size()): + print(list[i].name, "\n ", list[i]) + +func get_signal_parameters(object, signal_name, index=-1): + var params = null + if(is_watching(object, signal_name)): + var all_params = _watched_signals[object][signal_name] + if(all_params.size() > 0): + if(index == -1): + index = all_params.size() -1 + params = all_params[index] + return params + +func is_watching_object(object): + return _watched_signals.has(object) + +func is_watching(object, signal_name): + return _watched_signals.has(object) and _watched_signals[object].has(signal_name) + +func clear(): + for obj in _watched_signals: + if(GutUtils.is_not_freed(obj)): + for signal_name in _watched_signals[obj]: + obj.disconnect(signal_name, Callable(self,'_on_watched_signal')) + _watched_signals.clear() + +# Returns a list of all the signal names that were emitted by the object. +# If the object is not being watched then an empty list is returned. +func get_signals_emitted(obj): + var emitted = [] + if(is_watching_object(obj)): + for signal_name in _watched_signals[obj]: + if(_watched_signals[obj][signal_name].size() > 0): + emitted.append(signal_name) + + return emitted + + +func get_signal_summary(obj): + var emitted = {} + if(is_watching_object(obj)): + for signal_name in _watched_signals[obj]: + if(_watched_signals[obj][signal_name].size() > 0): + # maybe this could return parameters if any were sent. should + # have an empty list if no parameters were ever sent to the + # signal. Or this all just gets moved into print_signal_summary + # since this wouldn't be that useful without more data in the + # summary. + var entry = { + emit_count = get_emit_count(obj, signal_name) + } + emitted[signal_name] = entry + + return emitted + + +func print_signal_summary(obj): + if(!is_watching_object(obj)): + var msg = str('Not watching signals for ', obj) + GutUtils.get_logger().warn(msg) + return + + var summary = get_signal_summary(obj) + print(obj, '::Signals') + var sorted = summary.keys() + sorted.sort() + for key in sorted: + print(' - ', key, ' x ', summary[key].emit_count) diff --git a/addons/gut/signal_watcher.gd.uid b/addons/gut/signal_watcher.gd.uid new file mode 100644 index 0000000..f2ca904 --- /dev/null +++ b/addons/gut/signal_watcher.gd.uid @@ -0,0 +1 @@ +uid://yj7vo3wcr68q diff --git a/addons/gut/source_code_pro.fnt b/addons/gut/source_code_pro.fnt new file mode 100644 index 0000000..eb6b9b8 Binary files /dev/null and b/addons/gut/source_code_pro.fnt differ diff --git a/addons/gut/source_code_pro.fnt.import b/addons/gut/source_code_pro.fnt.import new file mode 100644 index 0000000..a2bf849 --- /dev/null +++ b/addons/gut/source_code_pro.fnt.import @@ -0,0 +1,17 @@ +[remap] + +importer="font_data_bmfont" +type="FontFile" +uid="uid://cfawt18qr4256" +path="res://.godot/imported/source_code_pro.fnt-042fb383b3c7b4c19e67c852f7fbefca.fontdata" + +[deps] + +source_file="res://addons/gut/source_code_pro.fnt" +dest_files=["res://.godot/imported/source_code_pro.fnt-042fb383b3c7b4c19e67c852f7fbefca.fontdata"] + +[params] + +fallbacks=[] +compress=true +scaling_mode=2 diff --git a/addons/gut/spy.gd b/addons/gut/spy.gd new file mode 100644 index 0000000..0441ba9 --- /dev/null +++ b/addons/gut/spy.gd @@ -0,0 +1,116 @@ +# { +# instance_id_or_path1:{ +# method1:[ [p1, p2], [p1, p2] ], +# method2:[ [p1, p2], [p1, p2] ] +# }, +# instance_id_or_path1:{ +# method1:[ [p1, p2], [p1, p2] ], +# method2:[ [p1, p2], [p1, p2] ] +# }, +# } +var _calls = {} +var _lgr = GutUtils.get_logger() +var _compare = GutUtils.Comparator.new() + +func _find_parameters(call_params, params_to_find): + var found = false + var idx = 0 + while(idx < call_params.size() and !found): + var result = _compare.deep(call_params[idx], params_to_find) + if(result.are_equal): + found = true + else: + idx += 1 + return found + + +func _get_params_as_string(params): + var to_return = '' + if(params == null): + return '' + + for i in range(params.size()): + if(params[i] == null): + to_return += 'null' + else: + if(typeof(params[i]) == TYPE_STRING): + to_return += str('"', params[i], '"') + else: + to_return += str(params[i]) + if(i != params.size() -1): + to_return += ', ' + return to_return + + +func add_call(variant, method_name, parameters=null): + if(!_calls.has(variant)): + _calls[variant] = {} + + if(!_calls[variant].has(method_name)): + _calls[variant][method_name] = [] + + _calls[variant][method_name].append(parameters) + + +func was_called(variant, method_name, parameters=null): + var to_return = false + if(_calls.has(variant) and _calls[variant].has(method_name)): + if(parameters): + to_return = _find_parameters(_calls[variant][method_name], parameters) + else: + to_return = true + return to_return + + +func get_call_parameters(variant, method_name, index=-1): + var to_return = null + var get_index = -1 + + if(_calls.has(variant) and _calls[variant].has(method_name)): + var call_size = _calls[variant][method_name].size() + if(index == -1): + # get the most recent call by default + get_index = call_size -1 + else: + get_index = index + + if(get_index < call_size): + to_return = _calls[variant][method_name][get_index] + else: + _lgr.error(str('Specified index ', index, ' is outside range of the number of registered calls: ', call_size)) + + return to_return + + +func call_count(instance, method_name, parameters=null): + var to_return = 0 + + if(was_called(instance, method_name)): + if(parameters): + for i in range(_calls[instance][method_name].size()): + if(_calls[instance][method_name][i] == parameters): + to_return += 1 + else: + to_return = _calls[instance][method_name].size() + return to_return + + +func clear(): + _calls = {} + + +func get_call_list_as_string(instance): + var to_return = '' + if(_calls.has(instance)): + for method in _calls[instance]: + for i in range(_calls[instance][method].size()): + to_return += str(method, '(', _get_params_as_string(_calls[instance][method][i]), ")\n") + return to_return + + +func get_logger(): + return _lgr + + +func set_logger(logger): + _lgr = logger diff --git a/addons/gut/spy.gd.uid b/addons/gut/spy.gd.uid new file mode 100644 index 0000000..40add51 --- /dev/null +++ b/addons/gut/spy.gd.uid @@ -0,0 +1 @@ +uid://blj7je6n53r51 diff --git a/addons/gut/strutils.gd b/addons/gut/strutils.gd new file mode 100644 index 0000000..3a9be4d --- /dev/null +++ b/addons/gut/strutils.gd @@ -0,0 +1,178 @@ +class_name GutStringUtils + +# Hash containing all the built in types in Godot. This provides an English +# name for the types that corosponds with the type constants defined in the +# engine. +var types = {} + +func _init_types_dictionary(): + types[TYPE_NIL] = 'NIL' + types[TYPE_AABB] = 'AABB' + types[TYPE_ARRAY] = 'ARRAY' + types[TYPE_BASIS] = 'BASIS' + types[TYPE_BOOL] = 'BOOL' + types[TYPE_CALLABLE] = 'CALLABLE' + types[TYPE_COLOR] = 'COLOR' + types[TYPE_DICTIONARY] = 'DICTIONARY' + types[TYPE_FLOAT] = 'FLOAT' + types[TYPE_INT] = 'INT' + types[TYPE_MAX] = 'MAX' + types[TYPE_NODE_PATH] = 'NODE_PATH' + types[TYPE_OBJECT] = 'OBJECT' + types[TYPE_PACKED_BYTE_ARRAY] = 'PACKED_BYTE_ARRAY' + types[TYPE_PACKED_COLOR_ARRAY] = 'PACKED_COLOR_ARRAY' + types[TYPE_PACKED_FLOAT32_ARRAY] = 'PACKED_FLOAT32_ARRAY' + types[TYPE_PACKED_FLOAT64_ARRAY] = 'PACKED_FLOAT64_ARRAY' + types[TYPE_PACKED_INT32_ARRAY] = 'PACKED_INT32_ARRAY' + types[TYPE_PACKED_INT64_ARRAY] = 'PACKED_INT64_ARRAY' + types[TYPE_PACKED_STRING_ARRAY] = 'PACKED_STRING_ARRAY' + types[TYPE_PACKED_VECTOR2_ARRAY] = 'PACKED_VECTOR2_ARRAY' + types[TYPE_PACKED_VECTOR3_ARRAY] = 'PACKED_VECTOR3_ARRAY' + types[TYPE_PLANE] = 'PLANE' + types[TYPE_PROJECTION] = 'PROJECTION' + types[TYPE_QUATERNION] = 'QUATERNION' + types[TYPE_RECT2] = 'RECT2' + types[TYPE_RECT2I] = 'RECT2I' + types[TYPE_RID] = 'RID' + types[TYPE_SIGNAL] = 'SIGNAL' + types[TYPE_STRING_NAME] = 'STRING_NAME' + types[TYPE_STRING] = 'STRING' + types[TYPE_TRANSFORM2D] = 'TRANSFORM2D' + types[TYPE_TRANSFORM3D] = 'TRANSFORM3D' + types[TYPE_VECTOR2] = 'VECTOR2' + types[TYPE_VECTOR2I] = 'VECTOR2I' + types[TYPE_VECTOR3] = 'VECTOR3' + types[TYPE_VECTOR3I] = 'VECTOR3I' + types[TYPE_VECTOR4] = 'VECTOR4' + types[TYPE_VECTOR4I] = 'VECTOR4I' + +# Types to not be formatted when using _str +var _str_ignore_types = [ + TYPE_INT, TYPE_FLOAT, TYPE_STRING, + TYPE_NIL, TYPE_BOOL +] + +func _init(): + _init_types_dictionary() + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _get_filename(path): + return path.split('/')[-1] + +# ------------------------------------------------------------------------------ +# Gets the filename of an object passed in. This does not return the +# full path to the object, just the filename. +# ------------------------------------------------------------------------------ +func _get_obj_filename(thing): + var filename = null + + if(thing == null or + GutUtils.is_native_class(thing) or + !is_instance_valid(thing) or + str(thing) == '' or + typeof(thing) != TYPE_OBJECT or + GutUtils.is_double(thing)): + return + + if(thing.get_script() == null): + if(thing is PackedScene): + filename = _get_filename(thing.resource_path) + else: + # If it isn't a packed scene and it doesn't have a script then + # we do nothing. This just reads better. + pass + elif(!GutUtils.is_native_class(thing)): + var dict = inst_to_dict(thing) + filename = _get_filename(dict['@path']) + if(str(dict['@subpath']) != ''): + filename += str('/', dict['@subpath']) + + return filename + +# ------------------------------------------------------------------------------ +# Better object/thing to string conversion. Includes extra details about +# whatever is passed in when it can/should. +# ------------------------------------------------------------------------------ +func type2str(thing): + var filename = _get_obj_filename(thing) + var str_thing = str(thing) + + if(thing == null): + # According to str there is a difference between null and an Object + # that is somehow null. To avoid getting '[Object:null]' as output + # always set it to str(null) instead of str(thing). A null object + # will pass typeof(thing) == TYPE_OBJECT check so this has to be + # before that. + str_thing = str(null) + elif(typeof(thing) == TYPE_FLOAT): + if(!'.' in str_thing): + str_thing += '.0' + elif(typeof(thing) == TYPE_STRING): + str_thing = str('"', thing, '"') + elif(typeof(thing) in _str_ignore_types): + # do nothing b/c we already have str(thing) in + # to_return. I think this just reads a little + # better this way. + pass + elif(typeof(thing) == TYPE_OBJECT): + if(GutUtils.is_native_class(thing)): + str_thing = GutUtils.get_native_class_name(thing) + elif(GutUtils.is_double(thing)): + var double_path = _get_filename(thing.__gutdbl.thepath) + if(thing.__gutdbl.subpath != ''): + double_path += str('/', thing.__gutdbl.subpath) + elif(thing.__gutdbl.from_singleton != ''): + double_path = thing.__gutdbl.from_singleton + " Singleton" + + var double_type = "double" + if(thing.__gutdbl.is_partial): + double_type = "partial-double" + + str_thing += str("(", double_type, " of ", double_path, ")") + + filename = null + elif(types.has(typeof(thing))): + if(!str_thing.begins_with('(')): + str_thing = '(' + str_thing + ')' + str_thing = str(types[typeof(thing)], str_thing) + + if(filename != null): + str_thing += str('(', filename, ')') + return str_thing + +# ------------------------------------------------------------------------------ +# Returns the string truncated with an '...' in it. Shows the start and last +# 10 chars. If the string is smaller than max_size the entire string is +# returned. If max_size is -1 then truncation is skipped. +# ------------------------------------------------------------------------------ +func truncate_string(src, max_size): + var to_return = src + if(src.length() > max_size - 10 and max_size != -1): + to_return = str(src.substr(0, max_size - 10), '...', src.substr(src.length() - 10, src.length())) + return to_return + + +func _get_indent_text(times, pad): + var to_return = '' + for i in range(times): + to_return += pad + + return to_return + +func indent_text(text, times, pad): + if(times == 0): + return text + + var to_return = text + var ending_newline = '' + + if(text.ends_with("\n")): + ending_newline = "\n" + to_return = to_return.left(to_return.length() -1) + + var padding = _get_indent_text(times, pad) + to_return = to_return.replace("\n", "\n" + padding) + to_return += ending_newline + + return padding + to_return diff --git a/addons/gut/strutils.gd.uid b/addons/gut/strutils.gd.uid new file mode 100644 index 0000000..87b98e9 --- /dev/null +++ b/addons/gut/strutils.gd.uid @@ -0,0 +1 @@ +uid://dntjtiq2ppvmq diff --git a/addons/gut/stub_params.gd b/addons/gut/stub_params.gd new file mode 100644 index 0000000..30001a0 --- /dev/null +++ b/addons/gut/stub_params.gd @@ -0,0 +1,169 @@ +var _is_return_override = false +var _is_defaults_override = false +var _is_call_override = false +var _method_meta : Dictionary = {} + + +var _lgr = GutUtils.get_logger() +var logger = _lgr : + get: return _lgr + set(val): _lgr = val + +var return_val = null +var stub_target = null +var parameters = null # the parameter values to match method call on. +var stub_method = null +var call_super = false +var call_this = null + +# Whether this is a stub for default parameter values as they are defined in +# the script, and not an overridden default value. +var is_script_default = false + +var parameter_count = -1 : + get(): + _lgr.deprecated("parameter count deprecated") + return -1 + +# Default values for parameters. This is used to store default values for +# scripts and to override those values. I'm not sure if there is a need to +# override them anymore, since I think this was introduced for stubbing vararg +# methods, but you still can for now. This value should only be used if +# is_defaults_override is true. +var parameter_defaults = [] + +const NOT_SET = '|_1_this_is_not_set_1_|' + +func _init(target=null, method=null, _subpath=null): + stub_target = target + stub_method = method + + if(typeof(target) == TYPE_CALLABLE): + stub_target = target.get_object() + stub_method = target.get_method() + parameters = target.get_bound_arguments() + if(parameters.size() == 0): + parameters = null + elif(typeof(target) == TYPE_STRING): + if(target.is_absolute_path()): + stub_target = load(str(target)) + else: + _lgr.warn(str(target, ' is not a valid path')) + + if(stub_target is PackedScene): + stub_target = GutUtils.get_scene_script_object(stub_target) + + # this is used internally to stub default parameters for everything that is + # doubled...or something. Look for stub_defaults_from_meta for usage. This + # behavior is not to be used by end users. + if(typeof(method) == TYPE_DICTIONARY): + _method_meta = method + _load_defaults_from_metadata(method) + is_script_default = true + + +func _load_defaults_from_metadata(meta): + stub_method = meta.name + var values = meta.default_args.duplicate() + while (values.size() < meta.args.size()): + values.push_front(null) + + param_defaults(values) + + +func _get_method_meta(): + if(_method_meta == {} and typeof(stub_target) == TYPE_OBJECT): + var found_meta = GutUtils.get_method_meta(stub_target, stub_method) + if(found_meta != null): + _method_meta = found_meta + return _method_meta + + +# ------------------------- +# Public +# ------------------------- +func to_return(val): + return_val = val + call_super = false + _is_return_override = true + + return self + + +func to_do_nothing(): + to_return(null) + return self + + +func to_call_super(): + call_super = true + _is_call_override = true + return self + + +func to_call(callable : Callable): + call_this = callable + _is_call_override = true + return self + + +func when_passed(p1=NOT_SET,p2=NOT_SET,p3=NOT_SET,p4=NOT_SET,p5=NOT_SET,p6=NOT_SET,p7=NOT_SET,p8=NOT_SET,p9=NOT_SET,p10=NOT_SET): + parameters = [p1,p2,p3,p4,p5,p6,p7,p8,p9,p10] + var idx = 0 + while(idx < parameters.size()): + if(str(parameters[idx]) == NOT_SET): + parameters.remove_at(idx) + else: + idx += 1 + return self + + +func param_count(_x): + _lgr.deprecated("Stubbing param_count is no longer required or supported.") + return self + + +func param_defaults(values): + var meta = _get_method_meta() + if(meta != {} and meta.flags & METHOD_FLAG_VARARG): + _lgr.error("Cannot stub defaults for methods with varargs.") + else: + parameter_defaults = values + _is_defaults_override = true + return self + + +func is_default_override_only(): + return is_defaults_override() and !is_return_override() and !is_call_override() + + +func is_return_override(): + return _is_return_override + + +func is_defaults_override(): + return _is_defaults_override + + +func is_call_override(): + return _is_call_override + + +func to_s(): + var base_string = str(stub_target, '.', stub_method) + + if(parameter_defaults.size() > 0): + base_string += str(" defaults ", parameter_defaults) + + if(call_super): + base_string += " to call SUPER" + + if(call_this != null): + base_string += str(" to call ", call_this) + + if(parameters != null): + base_string += str(' with params (', parameters, ') returns ', return_val) + else: + base_string += str(' returns ', return_val) + + return base_string diff --git a/addons/gut/stub_params.gd.uid b/addons/gut/stub_params.gd.uid new file mode 100644 index 0000000..a5ba1a3 --- /dev/null +++ b/addons/gut/stub_params.gd.uid @@ -0,0 +1 @@ +uid://dblktyc7hyt4f diff --git a/addons/gut/stubber.gd b/addons/gut/stubber.gd new file mode 100644 index 0000000..8e4d4cc --- /dev/null +++ b/addons/gut/stubber.gd @@ -0,0 +1,242 @@ + +static var _class_db_name_hash = {} : + get(): + if(_class_db_name_hash == {}): + _class_db_name_hash = _make_crazy_dynamic_over_engineered_class_db_hash() + return _class_db_name_hash + + +# So, I couldn't figure out how to get to a reference for a GDNative Class +# using a string. ClassDB has all thier names...so I made a hash using those +# names and the classes. Then I dynmaically make a script that has that as +# the source and grab the hash out of it and return it. Super Rube Golbergery, +# but tons of fun. +static func _make_crazy_dynamic_over_engineered_class_db_hash(): + var text = "var all_the_classes: Dictionary = {\n" + for classname in ClassDB.get_class_list(): + if(ClassDB.can_instantiate(classname)): + text += str('"', classname, '": ', classname, ", \n") + else: + text += str('# ', classname, "\n") + text += "}" + var inst = GutUtils.create_script_from_source(text).new() + return inst.all_the_classes + + +# ------------- +# returns{} and parameters {} have the followin structure +# ------------- +# { +# inst_id_or_path1:{ +# method_name1: [StubParams, StubParams], +# method_name2: [StubParams, StubParams] +# }, +# inst_id_or_path2:{ +# method_name1: [StubParams, StubParams], +# method_name2: [StubParams, StubParams] +# } +# } +var returns = {} +var _lgr = GutUtils.get_logger() +var _strutils = GutUtils.Strutils.new() + + +func _find_matches(obj, method): + var matches = [] + var last_not_null_parent = null + + # Search for what is passed in first. This could be a class or an instance. + # We want to find the instance before we find the class. If we do not have + # an entry for the instance then see if we have an entry for the class. + if(returns.has(obj) and returns[obj].has(method)): + matches = returns[obj][method] + elif(GutUtils.is_instance(obj)): + var parent = obj.get_script() + var found = false + while(parent != null and !found): + found = returns.has(parent) + + if(!found): + last_not_null_parent = parent + parent = parent.get_base_script() + + # Could not find the script so check to see if a native class of this + # type was stubbed. + if(!found): + var base_type = last_not_null_parent.get_instance_base_type() + if(_class_db_name_hash.has(base_type)): + parent = _class_db_name_hash[base_type] + found = returns.has(parent) + + if(found and returns[parent].has(method)): + matches = returns[parent][method] + + return matches + + +# Searches returns for an entry that matches the instance or the class that +# passed in obj is. +# +# obj can be an instance, class, or a path. +func _find_stub(obj, method, parameters=null, find_overloads=false): + var to_return = null + var matches = _find_matches(obj, method) + + if(matches.size() == 0): + return null + + var param_match = null + var null_match = null + var overload_match = null + + for i in range(matches.size()): + var cur_stub = matches[i] + if(cur_stub.parameters == parameters): + param_match = cur_stub + + if(cur_stub.parameters == null and !cur_stub.is_default_override_only()): + null_match = cur_stub + + if(cur_stub.is_defaults_override): + if(overload_match == null || overload_match.is_script_default): + overload_match = cur_stub + + if(find_overloads and overload_match != null): + to_return = overload_match + # We have matching parameter values so return the stub value for that + elif(param_match != null): + to_return = param_match + # We found a case where the parameters were not specified so return + # parameters for that. Only do this if the null match is not *just* + # a paramerter override stub. + elif(null_match != null): + to_return = null_match + + return to_return + + + +# ############## +# Public +# ############## + +func add_stub(stub_params): + stub_params._lgr = _lgr + var key = stub_params.stub_target + + if(!returns.has(key)): + returns[key] = {} + + if(!returns[key].has(stub_params.stub_method)): + returns[key][stub_params.stub_method] = [] + + returns[key][stub_params.stub_method].append(stub_params) + + +# Gets a stubbed return value for the object and method passed in. If the +# instance was stubbed it will use that, otherwise it will use the path and +# subpath of the object to try to find a value. +# +# It will also use the optional list of parameter values to find a value. If +# the object was stubbed with no parameters than any parameters will match. +# If it was stubbed with specific parameter values then it will try to match. +# If the parameters do not match BUT there was also an empty parameter list stub +# then it will return those. +# If it cannot find anything that matches then null is returned.for +# +# Parameters +# obj: this should be an instance of a doubled object. +# method: the method called +# parameters: optional array of parameter vales to find a return value for. +func get_return(obj, method, parameters=null): + var stub_info = _find_stub(obj, method, parameters) + + if(stub_info != null): + return stub_info.return_val + else: + _lgr.info(str('Call to [', method, '] was not stubbed for the supplied parameters ', parameters, '. Null was returned.')) + return null + + +func should_call_super(obj, method, parameters=null): + var stub_info = _find_stub(obj, method, parameters) + + var is_partial = false + if(typeof(obj) != TYPE_STRING): # some stubber tests test with strings + is_partial = obj.__gutdbl.is_partial + var should = is_partial + + if(stub_info != null): + should = stub_info.call_super + elif(!is_partial): + # this log message is here because of how the generated doubled scripts + # are structured. With this log msg here, you will only see one + # "unstubbed" info instead of multiple. + _lgr.info('Unstubbed call to ' + method + '::' + _strutils.type2str(obj)) + should = false + + return should + + +func get_call_this(obj, method, parameters=null): + var stub_info = _find_stub(obj, method, parameters) + + if(stub_info != null): + return stub_info.call_this + + +func get_default_value(obj, method, p_index): + var matches = _find_matches(obj, method) + var the_defaults = [] + var script_defaults = [] + var i = matches.size() -1 + + while(i >= 0 and the_defaults.is_empty()): + if(matches[i].is_defaults_override()): + if(matches[i].is_script_default): + script_defaults = matches[i].parameter_defaults + else: + the_defaults = matches[i].parameter_defaults + i -= 1 + + if(the_defaults.is_empty() and !script_defaults.is_empty()): + the_defaults = script_defaults + + var to_return = null + if(the_defaults.size() > p_index): + to_return = the_defaults[p_index] + + return to_return + + +func clear(): + returns.clear() + + +func get_logger(): + return _lgr + + +func set_logger(logger): + _lgr = logger + + +func to_s(): + var text = '' + for thing in returns: + text += str("-- ", thing, " --\n") + for method in returns[thing]: + text += str("\t", method, "\n") + for i in range(returns[thing][method].size()): + text += "\t\t" + returns[thing][method][i].to_s() + "\n" + + if(text == ''): + text = 'Stubber is empty'; + + return text + + +func stub_defaults_from_meta(target, method_meta): + var params = GutUtils.StubParams.new(target, method_meta) + params.is_script_default = true + add_stub(params) diff --git a/addons/gut/stubber.gd.uid b/addons/gut/stubber.gd.uid new file mode 100644 index 0000000..3db8dc1 --- /dev/null +++ b/addons/gut/stubber.gd.uid @@ -0,0 +1 @@ +uid://cjrpyjnk8lkq8 diff --git a/addons/gut/summary.gd b/addons/gut/summary.gd new file mode 100644 index 0000000..c95982d --- /dev/null +++ b/addons/gut/summary.gd @@ -0,0 +1,210 @@ +# ------------------------------------------------------------------------------ +# Prints things, mostly. Knows too much about gut.gd, but it's only supposed to +# work with gut.gd, so I'm fine with that. +# ------------------------------------------------------------------------------ +# a _test_collector to use when one is not provided. +var _gut = null + + +func _init(gut=null): + _gut = gut + +# --------------------- +# Private +# --------------------- +func _log_end_run_header(gut): + var lgr = gut.get_logger() + lgr.log('==============================================', lgr.fmts.yellow) + lgr.log("= Run Summary", lgr.fmts.yellow) + lgr.log('==============================================', lgr.fmts.yellow) + + +func _log_what_was_run(gut): + if(!GutUtils.is_null_or_empty(gut._select_script)): + gut.p('Ran Scripts matching "' + gut._select_script + '"') + if(!GutUtils.is_null_or_empty(gut._unit_test_name)): + gut.p('Ran Tests matching "' + gut._unit_test_name + '"') + if(!GutUtils.is_null_or_empty(gut._inner_class_name)): + gut.p('Ran Inner Classes matching "' + gut._inner_class_name + '"') + + +func _total_fmt(text, value): + var space = 18 + if(str(value) == '0'): + value = 'none' + return str(text.rpad(space), str(value).lpad(5)) + + +func _log_non_zero_total(text, value, lgr): + if(str(value) != '0'): + lgr.log(_total_fmt(text, value)) + return 1 + else: + return 0 + + +func _log_totals(gut, totals): + var lgr = gut.get_logger() + lgr.log() + + # lgr.log("---- Totals ----") + lgr.log("Totals") + lgr.log("------") + var issue_count = 0 + issue_count += _log_non_zero_total('Errors', totals.errors, lgr) + issue_count += _log_non_zero_total('Warnings', totals.warnings, lgr) + issue_count += _log_non_zero_total('Deprecated', totals.deprecated, lgr) + if(issue_count > 0): + lgr.log("") + + lgr.log(_total_fmt( 'Scripts', totals.scripts)) + lgr.log(_total_fmt( 'Tests', gut.get_test_collector().get_ran_test_count())) + lgr.log(_total_fmt( 'Passing Tests', totals.passing_tests)) + _log_non_zero_total('Failing Tests', totals.failing_tests, lgr) + _log_non_zero_total('Risky/Pending', totals.risky + totals.pending, lgr) + if(totals.failing == 0): + lgr.log(_total_fmt( 'Asserts', totals.passing + totals.failing)) + else: + lgr.log(_total_fmt( 'Asserts', str(totals.passing, '/', totals.passing + totals.failing))) + _log_non_zero_total( 'Orphans', totals.orphans, lgr) + lgr.log(_total_fmt( 'Time', str(gut.get_elapsed_time(), 's'))) + + return totals + + +func _log_nothing_run(gut): + var lgr = gut.get_logger() + lgr.error("Nothing was run.") + lgr.log('On the one hand nothing failed, on the other hand nothing did anything.') + _log_what_was_run(gut) + + +# --------------------- +# Public +# --------------------- +func log_all_non_passing_tests(gut=_gut): + var test_collector = gut.get_test_collector() + var lgr = gut.get_logger() + + var to_return = { + passing = 0, + non_passing = 0 + } + + for test_script in test_collector.scripts: + lgr.set_indent_level(0) + + if(test_script.was_skipped or test_script.get_fail_count() > 0 or test_script.get_pending_count() > 0): + lgr.log("\n" + test_script.get_full_name(), lgr.fmts.underline) + + if(test_script.was_skipped): + lgr.inc_indent() + var skip_msg = str('[Risky] Script was skipped: ', test_script.skip_reason) + lgr.log(skip_msg, lgr.fmts.yellow) + lgr.dec_indent() + + var test_fail_count = 0 + for test in test_script.tests: + if(test.was_run): + if(test.is_passing()): + to_return.passing += 1 + else: + to_return.non_passing += 1 + lgr.log(str('- ', test.name)) + lgr.inc_indent() + + for i in range(test.fail_texts.size()): + lgr.failed(test.fail_texts[i]) + test_fail_count += 1 + for i in range(test.pending_texts.size()): + lgr.pending(test.pending_texts[i]) + if(test.is_risky()): + lgr.risky('Did not assert') + lgr.dec_indent() + + if(test_script.get_fail_count() > test_fail_count): + lgr.failed("before_all/after_all assert failed") + + return to_return + + +func log_the_final_line(totals, gut): + var lgr = gut.get_logger() + var grand_total_text = "" + var grand_total_fmt = lgr.fmts.none + if(totals.failing_tests > 0): + grand_total_text = str(totals.failing_tests, " failing tests") + grand_total_fmt = lgr.fmts.red + elif(totals.failing > 0): # no failing tests, but some failing asserts + grand_total_text = str(totals.failing, " assert(s) in before_all/after_all methods failed") + grand_total_fmt = lgr.fmts.red + elif(totals.risky > 0 or totals.pending > 0): + grand_total_text = str(totals.risky + totals.pending, " pending/risky tests.") + grand_total_fmt = lgr.fmts.yellow + else: + grand_total_text = "All tests passed!" + grand_total_fmt = lgr.fmts.green + + lgr.log(str("---- ", grand_total_text, " ----"), grand_total_fmt) + + +func log_totals(gut, totals): + var lgr = gut.get_logger() + var orig_indent = lgr.get_indent_level() + lgr.set_indent_level(0) + _log_totals(gut, totals) + lgr.set_indent_level(orig_indent) + + +func get_totals(gut=_gut): + var tc = gut.get_test_collector() + var lgr = gut.get_logger() + + var totals = { + failing = 0, + failing_tests = 0, + passing = 0, + passing_tests = 0, + pending = 0, + risky = 0, + scripts = tc.get_ran_script_count(), + tests = 0, + + deprecated = lgr.get_deprecated().size(), + errors = lgr.get_errors().size(), + warnings = lgr.get_warnings().size(), + } + + for s in tc.scripts: + # assert totals + totals.passing += s.get_pass_count() + totals.pending += s.get_pending_count() + totals.failing += s.get_fail_count() + + # test totals + totals.tests += s.get_ran_test_count() + totals.passing_tests += s.get_passing_test_count() + totals.failing_tests += s.get_failing_test_count() + totals.risky += s.get_risky_count() + + totals.orphans = gut.get_orphan_counter().orphan_count() + + return totals + + +func log_end_run(gut=_gut): + var totals = get_totals(gut) + if(totals.tests == 0): + _log_nothing_run(gut) + return + + _log_end_run_header(gut) + var lgr = gut.get_logger() + + log_all_non_passing_tests(gut) + log_totals(gut, totals) + lgr.log("\n") + + _log_what_was_run(gut) + log_the_final_line(totals, gut) + lgr.log("") diff --git a/addons/gut/summary.gd.uid b/addons/gut/summary.gd.uid new file mode 100644 index 0000000..d8ccd92 --- /dev/null +++ b/addons/gut/summary.gd.uid @@ -0,0 +1 @@ +uid://byxw2c883i2kr diff --git a/addons/gut/test.gd b/addons/gut/test.gd new file mode 100644 index 0000000..515d6d3 --- /dev/null +++ b/addons/gut/test.gd @@ -0,0 +1,2754 @@ +class_name GutTest +extends Node +## This is the base class for your GUT test scripts.[br] +## [br] +## GUT Wiki: [url=https://gut.readthedocs.io]https://gut.readthedocs.io[/url] +## [br] +## Simple Example +## [codeblock] +## extends GutTest +## +## func before_all(): +## gut.p("before_all called" +## +## func before_each(): +## gut.p("before_each called") +## +## func after_each(): +## gut.p("after_each called") +## +## func after_all(): +## gut.p("after_all called") +## +## func test_assert_eq_letters(): +## assert_eq("asdf", "asdf", "Should pass") +## +## func test_assert_eq_number_not_equal(): +## assert_eq(1, 2, "Should fail. 1 != 2") +## [/codeblock] + + +# Normalizes p1 and p2 into object/signal_name/signal_ref(sig). Additional +# parameters are optional and will be placed into the others array. This +# class is used in refactoring signal methods to accept a reference to the +# signal instead an object and the signal name. +class SignalAssertParameters: + var object = null + var signal_name = null + var sig = null + var others := [] + + func _init(p1, p2, p3=null, p4=null, p5=null, p6=null): + others = [p3, p4, p5, p6] + if(p1 is Signal): + object = p1.get_object() + signal_name = p1.get_name() + others.push_front(p2) + sig = p1 + else: + object = p1 + signal_name = p2 + sig = object.get(signal_name) + + +const EDITOR_PROPERTY = PROPERTY_USAGE_SCRIPT_VARIABLE | PROPERTY_USAGE_DEFAULT +const VARIABLE_PROPERTY = PROPERTY_USAGE_SCRIPT_VARIABLE +# Convenience copy of GutUtils.DOUBLE_STRATEGY +var DOUBLE_STRATEGY = GutUtils.DOUBLE_STRATEGY + +## Reference to [addons/gut/parameter_factory.gd] script. +var ParameterFactory = GutUtils.ParameterFactory +## @ignore +var CompareResult = GutUtils.CompareResult +## Reference to [GutInputFactory] class that was originally used to reference +## the Input Factory before the class_name was introduced. +var InputFactory = GutInputFactory +## Reference to [GutInputSender]. This was the way you got to the [GutInputSender] +## before it was given a [code]class_name[/code] +var InputSender = GutUtils.InputSender + +# Need a reference to the instance that is running the tests. This +# is set by the gut class when it runs the test script. +var gut: GutMain = null +# Reference to the collected_script.gd instance that was used to create this. +# This makes getting to meta data about the test easier. This is set by +# collected_script.get_new(). +var collected_script = null +var wait_log_delay = .5 : + set(val): + if(_awaiter != null): + _awaiter.await_logger.wait_log_delay = val + wait_log_delay = val +var _compare = GutUtils.Comparator.new() +var _disable_strict_datatype_checks = false +# Holds all the text for a test's fail/pass. This is used for testing purposes +# to see the text of a failed sub-test in test_test.gd +var _fail_pass_text = [] +# Summary counts for the test. +var _summary = { + asserts = 0, + passed = 0, + failed = 0, + tests = 0, + pending = 0 +} + +# This is used to watch signals so we can make assertions about them. +var _signal_watcher = load('res://addons/gut/signal_watcher.gd').new() +var _lgr = GutUtils.get_logger() +var _strutils = GutUtils.Strutils.new() +var _awaiter = null +var _was_ready_called = false + + +# I haven't decided if we should be using _ready or not. Right now gut.gd will +# call this if _ready was not called (because it was overridden without a super +# call). Maybe gut.gd should just call _do_ready_stuff (after we rename it to +# something better). I'm leaving all this as it is until it bothers me more. +func _do_ready_stuff(): + _awaiter = GutUtils.Awaiter.new() + _awaiter.await_logger.wait_log_delay = wait_log_delay + add_child(_awaiter) + _was_ready_called = true + + +func _ready(): + _do_ready_stuff() + + +func _notification(what): + # Tests are never expected to re-enter the tree. Tests are removed from the + # tree after they are run. + if(what == NOTIFICATION_EXIT_TREE): + # print(_strutils.type2str(self), ': exit_tree') + _awaiter.queue_free() + elif(what == NOTIFICATION_PREDELETE): + # print(_strutils.type2str(self), ': predelete') + if(is_instance_valid(_awaiter)): + _awaiter.queue_free() + + +#region Private +# ---------------- + + +func _str(thing): + return _strutils.type2str(thing) + + +func _str_precision(value, precision): + var to_return = _str(value) + var format = str('%.', precision, 'f') + if(typeof(value) == TYPE_FLOAT): + to_return = format % value + elif(typeof(value) == TYPE_VECTOR2): + to_return = str('VECTOR2(', format % value.x, ', ', format %value.y, ')') + elif(typeof(value) == TYPE_VECTOR3): + to_return = str('VECTOR3(', format % value.x, ', ', format %value.y, ', ', format % value.z, ')') + + return to_return + + +# Fail an assertion. Causes test and script to fail as well. +func _fail(text): + _summary.asserts += 1 + _summary.failed += 1 + _fail_pass_text.append('failed: ' + text) + if(gut): + _lgr.failed(gut.get_call_count_text() + text) + gut._fail(text) + + +# Pass an assertion. +func _pass(text): + _summary.asserts += 1 + _summary.passed += 1 + _fail_pass_text.append('passed: ' + text) + if(gut): + _lgr.passed(text) + gut._pass(text) + + +# Checks if the datatypes passed in match. If they do not then this will cause +# a fail to occur. If they match then TRUE is returned, FALSE if not. This is +# used in all the assertions that compare values. +func _do_datatypes_match__fail_if_not(got, expected, text): + var did_pass = true + + if(!_disable_strict_datatype_checks): + var got_type = typeof(got) + var expect_type = typeof(expected) + if(got_type != expect_type and got != null and expected != null): + # If we have a mismatch between float and int (types 2 and 3) then + # print out a warning but do not fail. + if([2, 3].has(got_type) and [2, 3].has(expect_type)): + _lgr.warn(str('Warn: Float/Int comparison. Got ', _strutils.types[got_type], + ' but expected ', _strutils.types[expect_type])) + elif([TYPE_STRING, TYPE_STRING_NAME].has(got_type) and [TYPE_STRING, TYPE_STRING_NAME].has(expect_type)): + pass + else: + _fail('Cannot compare ' + _strutils.types[got_type] + '[' + _str(got) + '] to ' + \ + _strutils.types[expect_type] + '[' + _str(expected) + ']. ' + text) + did_pass = false + + return did_pass + + +# Create a string that lists all the methods that were called on an spied +# instance. +func _get_desc_of_calls_to_instance(inst): + var BULLET = ' * ' + var calls = gut.get_spy().get_call_list_as_string(inst) + # indent all the calls + calls = BULLET + calls.replace("\n", "\n" + BULLET) + # remove_at trailing newline and bullet + calls = calls.substr(0, calls.length() - BULLET.length() - 1) + return "Calls made on " + str(inst) + "\n" + calls + + + +# Signal assertion helper. Do not call directly, use _can_make_signal_assertions +func _fail_if_does_not_have_signal(object, signal_name): + var did_fail = false + if(!_signal_watcher.does_object_have_signal(object, signal_name)): + _fail(str('Object ', object, ' does not have the signal [', signal_name, ']')) + did_fail = true + return did_fail + + +# Signal assertion helper. Do not call directly, use _can_make_signal_assertions +func _fail_if_not_watching(object): + var did_fail = false + if(!_signal_watcher.is_watching_object(object)): + _fail(str('Cannot make signal assertions because the object ', object, \ + ' is not being watched. Call watch_signals(some_object) to be able to make assertions about signals.')) + did_fail = true + return did_fail + + +# Returns text that contains original text and a list of all the signals that +# were emitted for the passed in object. +func _get_fail_msg_including_emitted_signals(text, object): + return str(text," (Signals emitted: ", _signal_watcher.get_signals_emitted(object), ")") + + +# This validates that parameters is an array and generates a specific error +# and a failure with a specific message +func _fail_if_parameters_not_array(parameters): + var invalid = parameters != null and typeof(parameters) != TYPE_ARRAY + if(invalid): + _lgr.error('The "parameters" parameter must be an array of expected parameter values.') + _fail('Cannot compare parameter values because an array was not passed.') + return invalid + + +# A bunch of common checkes used when validating a double/method pair. If +# everything is ok then an empty string is returned, otherwise the message +# is returned. +func _get_bad_method_message(inst, method_name, what_you_cant_do): + var to_return = '' + + if(!inst.has_method(method_name)): + to_return = str("You cannot ", what_you_cant_do, " [", method_name, "] because the method does not exist. ", + "This can happen if the method is virtual and not overloaded (i.e. _ready) ", + "or you have mistyped the name of the method.") + elif(!inst.__gutdbl_values.doubled_methods.has(method_name)): + to_return = str("You cannot ", what_you_cant_do, " [", method_name, "] because ", + _str(inst), ' does not overload it or it was ignored with ', + 'ignore_method_when_doubling. See Doubling ', + 'Strategy in the wiki for details on including non-overloaded ', + 'methods in a double.') + + return to_return + + +func _fail_if_not_double_or_does_not_have_method(inst, method_name): + var to_return = OK + + if(!GutUtils.is_double(inst)): + _fail(str("An instance of a Double was expected, you passed: ", _str(inst))) + to_return = ERR_INVALID_DATA + else: + var msg = _get_bad_method_message(inst, method_name, 'spy on') + if(msg != ''): + _fail(msg) + to_return = ERR_INVALID_DATA + + return to_return + + +func _create_obj_from_type(type): + var obj = null + if type.is_class("PackedScene"): + obj = type.instantiate() + add_child(obj) + else: + obj = type.new() + return obj + + +# Converts a Callabe passed through inst or inst/method_name/parameters into a +# hash so that methods that interact with Spy can accept both more easily. +func _convert_spy_args(inst, method_name, parameters): + var to_return = { + 'object':inst, + 'method_name':method_name, + 'arguments':parameters, + 'invalid_message':'ok' + } + + if(inst is Callable): + if(parameters != null): + to_return.invalid_message =\ + "3rd parameter to assert_called not supported when using a Callable." + elif(method_name != null): + to_return.invalid_message =\ + "2nd parameter to assert_called not supported when using a Callable." + else: + if(inst.get_bound_arguments_count() > 0): + to_return.arguments = inst.get_bound_arguments() + to_return.method_name = inst.get_method() + to_return.object = inst.get_object() + + return to_return + + +func _get_typeof_string(the_type): + var to_return = "" + if(_strutils.types.has(the_type)): + to_return += str(the_type, '(', _strutils.types[the_type], ')') + else: + to_return += str(the_type) + return to_return + + +# Validates the singleton_name is a string and exists. Errors when conditions +# are not met. Returns true/false if singleton_name is valid or not. +func _validate_singleton_name(singleton_name): + var is_valid = true + if(typeof(singleton_name) != TYPE_STRING): + _lgr.error("double_singleton requires a Godot singleton name, you passed " + _str(singleton_name)) + is_valid = false + # Sometimes they have underscores in front of them, sometimes they do not. + # The doubler is smart enought of ind the right thing, so this has to be + # that smart as well. + elif(!ClassDB.class_exists(singleton_name) and !ClassDB.class_exists('_' + singleton_name)): + var txt = str("The singleton [", singleton_name, "] could not be found. ", + "Check the GlobalScope page for a list of singletons.") + _lgr.error(txt) + is_valid = false + return is_valid + + +# Checks the object for 'get_' and 'set_' methods for the specified property. +# If found a warning is generated. +func _warn_for_public_accessors(obj, property_name): + var public_accessors = [] + var accessor_names = [ + str('get_', property_name), + str('is_', property_name), + str('set_', property_name) + ] + + for acc in accessor_names: + if(obj.has_method(acc)): + public_accessors.append(acc) + + if(public_accessors.size() > 0): + _lgr.warn (str('Public accessors ', public_accessors, ' found for property ', property_name)) + + +func _smart_double(thing, double_strat, partial): + var override_strat = GutUtils.nvl(double_strat, gut.get_doubler().get_strategy()) + var to_return = null + + if(thing is PackedScene): + if(partial): + to_return = gut.get_doubler().partial_double_scene(thing, override_strat) + else: + to_return = gut.get_doubler().double_scene(thing, override_strat) + + elif(GutUtils.is_native_class(thing)): + if(partial): + to_return = gut.get_doubler().partial_double_gdnative(thing) + else: + to_return = gut.get_doubler().double_gdnative(thing) + + elif(thing is GDScript): + if(partial): + to_return = gut.get_doubler().partial_double(thing, override_strat) + else: + to_return = gut.get_doubler().double(thing, override_strat) + + return to_return + + +# This is here to aid in the transition to the new doubling sytnax. Once this +# has been established it could be removed. We must keep the is_instance check +# going forward though. +func _are_double_parameters_valid(thing, p2, p3): + var bad_msg = "" + if(p3 != null or typeof(p2) == TYPE_STRING): + bad_msg += "Doubling using a subpath is not supported. Call register_inner_class and then pass the Inner Class to double().\n" + + if(typeof(thing) == TYPE_STRING): + bad_msg += "Doubling using the path to a script or scene is no longer supported. Load the script or scene and pass that to double instead.\n" + + if(GutUtils.is_instance(thing)): + bad_msg += "double requires a script, you passed an instance: " + _str(thing) + + if(bad_msg != ""): + _lgr.error(bad_msg) + + return bad_msg == "" + +# ---------------- +#endregion +#region Virtual Methods +# ---------------- + +## Virtual Method. This is run after the script has been prepped for execution, but before `before_all` is executed. If you implement this method and return `true` or a `String` (the string is displayed in the log) then GUT will stop executing the script and mark it as risky. You might want to do this because: +## - You are porting tests from 3.x to 4.x and you don't want to comment everything out.[br] +## - Skipping tests that should not be run when in `headless` mode such as input testing that does not work in headless.[br] +## [codeblock] +## func should_skip_script(): +## if DisplayServer.get_name() == "headless": +## return "Skip Input tests when running headless" +## [/codeblock] +## - If you have tests that would normally cause the debugger to break on an error, you can skip the script if the debugger is enabled so that the run is not interrupted.[br] +## [codeblock] +## func should_skip_script(): +## return EngineDebugger.is_active() +## [/codeblock] +func should_skip_script(): + return false + + +## Virtual method. Run once before anything else in the test script is run. +func before_all(): + pass + + +## Virtual method. Run before each test is executed +func before_each(): + pass + +## Virtual method. Run after each test is executed. +func after_each(): + pass + + +## Virtual method. Run after all tests have been run. +func after_all(): + pass + +# ---------------- +#endregion +#region Misc Public +# ---------------- +## Mark the current test as pending. +func pending(text=""): + _summary.pending += 1 + if(gut): + _lgr.pending(text) + gut._pending(text) + + +## Returns true if the test is passing as of the time of this call. False if not. +func is_passing(): + if(gut.get_current_test_object() != null and + !['before_all', 'after_all'].has(gut.get_current_test_object().name)): + return gut.get_current_test_object().is_passing() and \ + gut.get_current_test_object().assert_count > 0 + else: + _lgr.error('No current test object found. is_passing must be called inside a test.') + return null + + +## Returns true if the test is failing as of the time of this call. False if not. +func is_failing(): + if(gut.get_current_test_object() != null and + !['before_all', 'after_all'].has(gut.get_current_test_object().name)): + + return gut.get_current_test_object().is_failing() + else: + _lgr.error('No current test object found. is_failing must be called inside a test.') + return null + + +## Marks the test as passing. Does not override any failing asserts or calls to +## fail_test. Same as a passing assert. +func pass_test(text): + _pass(text) + + +## Marks the test as failing. Same as a failing assert. +func fail_test(text): + _fail(text) + +## @internal +func clear_signal_watcher(): + _signal_watcher.clear() + + +## Returns the current double strategy. +func get_double_strategy(): + return gut.get_doubler().get_strategy() + + +## Sets the double strategy for all tests in the script. This should usually +## be done in [method before_all]. The double strtegy can be set per +## run/script/double. See [wiki]Double-Strategy[/wiki] +func set_double_strategy(double_strategy): + gut.get_doubler().set_strategy(double_strategy) + + +## This method will cause Gut to pause before it moves on to the next test. +## This is useful for debugging, for instance if you want to investigate the +## screen or anything else after a test has finished executing. +## [br] +## Sometimes you get lazy, and you don't remove calls to +## [code skip-lint]pause_before_teardown[/code] after you are done with them. You can +## tell GUT to ignore calls to this method through the panel or +## the command line. Setting this in your `.gutconfig.json` file is recommended +## for CI/CD Pipelines. +func pause_before_teardown(): + gut.pause_before_teardown() + + +## @internal +func get_logger(): + return _lgr + +## @internal +func set_logger(logger): + _lgr = logger + + +## This must be called in order to make assertions based on signals being +## emitted. __Right now, this only supports signals that are emitted with 9 or +## less parameters.__ This can be extended but nine seemed like enough for now. +## The Godot documentation suggests that the limit is four but in my testing +## I found you can pass more. +## [br] +## This must be called in each test in which you want to make signal based +## assertions in. You can call it multiple times with different objects. +## You should not call it multiple times with the same object in the same test. +## The objects that are watched are cleared after each test (specifically right +## before `teardown` is called). Under the covers, Gut will connect to all the +## signals an object has and it will track each time they fire. You can then +## use the following asserts and methods to verify things are acting correct. +func watch_signals(object): + _signal_watcher.watch_signals(object) + + +## This will return the number of times a signal was fired. This gives you +## the freedom to make more complicated assertions if the spirit moves you. +## This will return -1 if the signal was not fired or the object was not being +## watched, or if the object does not have the signal. +## [br][br] +## Accepts either the object and the signal name or the signal. +func get_signal_emit_count(p1, p2=null): + var sp = SignalAssertParameters.new(p1, p2) + return _signal_watcher.get_emit_count(sp.object, sp.signal_name) + + +## If you need to inspect the parameters in order to make more complicate assertions, then this will give you access to +## the parameters of any watched signal. This works the same way that +## [code skip-lint]assert_signal_emitted_with_parameters[/code] does. It takes an object, signal name, and an optional +## index. If the index is not specified then the parameters from the most recent emission will be returned. If the +## object is not being watched, the signal was not fired, or the object does not have the signal then `null` will be +## returned. +## +## [br][br] +## [b]Signatures:[/b][br] +## - get_signal_parameters([param p1]:Signal, [param p2]:parameter-index (optional))[br] +## - get_signal_parameters([param p1]:object, [param p2]:signal name, [param p3]:parameter-index (optional)) [br] +## [br] +## [b]Examples:[/b] +## [codeblock] +## class SignalObject: +## signal some_signal +## signal other_signal +## +## +## func test_get_signal_parameters(): +## var obj = SignalObject.new() +## watch_signals(obj) +## obj.some_signal.emit(1, 2, 3) +## obj.some_signal.emit('a', 'b', 'c') +## +## # -- Passing -- +## # passes because get_signal_parameters returns the most recent emission +## # by default +## assert_eq(get_signal_parameters(obj, 'some_signal'), ['a', 'b', 'c']) +## assert_eq(get_signal_parameters(obj.some_signal), ['a', 'b', 'c']) +## +## assert_eq(get_signal_parameters(obj, 'some_signal', 0), [1, 2, 3]) +## assert_eq(get_signal_parameters(obj.some_signal, 0), [1, 2, 3]) +## +## # if the signal was not fired null is returned +## assert_null(get_signal_parameters(obj, 'other_signal')) +## # if the signal does not exist or isn't being watched null is returned +## assert_null(get_signal_parameters(obj, 'signal_dne')) +## +## # -- Failing -- +## assert_eq(get_signal_parameters(obj, 'some_signal'), [1, 2, 3]) +## assert_eq(get_signal_parameters(obj.some_signal, 0), ['a', 'b', 'c']) +## [/codeblock] +func get_signal_parameters(p1, p2=null, p3=-1): + var sp := SignalAssertParameters.new(p1, GutUtils.nvl(p2, -1), p3) + return _signal_watcher.get_signal_parameters(sp.object, sp.signal_name, sp.others[0]) + + +## Get the parameters for a method call to a doubled object. By default it will +## return the most recent call. You can optionally specify an index for which +## call you want to get the parameters for. +## +## Can be called using a Callable for the first parameter instead of specifying +## an object and method name. When you do this, the seoncd parameter is used +## as the index. +## +## Returns: +## * an array of parameter values if a call the method was found +## * null when a call to the method was not found or the index specified was +## invalid. +func get_call_parameters(object, method_name_or_index = -1, idx=-1): + var to_return = null + var index = idx + if(object is Callable): + index = method_name_or_index + method_name_or_index = null + var converted = _convert_spy_args(object, method_name_or_index, null) + + if(GutUtils.is_double(converted.object)): + to_return = gut.get_spy().get_call_parameters( + converted.object, converted.method_name, index) + else: + _lgr.error('You must pass a doulbed object to get_call_parameters.') + + return to_return + + +## Returns the call count for a method with optional paramter matching. +## +## Can be called with a Callable instead of an object, method_name, and +## parameters. Bound arguments will be used to match call arguments. +func get_call_count(object, method_name=null, parameters=null): + var converted = _convert_spy_args(object, method_name, parameters) + return gut.get_spy().call_count(converted.object, converted.method_name, converted.arguments) + + +## Simulate a number of frames by calling '_process' and '_physics_process' (if +## the methods exist) on an object and all of its descendents. The specified frame +## time, 'delta', will be passed to each simulated call. +## +## NOTE: Objects can disable their processing methods using 'set_process(false)' and +## 'set_physics_process(false)'. This is reflected in the 'Object' methods +## 'is_processing()' and 'is_physics_processing()', respectively. To make 'simulate' +## respect this status, for example if you are testing an object which toggles +## processing, pass 'check_is_processing' as 'true'. +func simulate(obj, times, delta, check_is_processing: bool = false): + gut.simulate(obj, times, delta, check_is_processing) + + +# ------------------------------------------------------------------------------ +## Replace the node at base_node.get_node(path) with with_this. All references +## to the node via $ and get_node(...) will now return with_this. with_this will +## get all the groups that the node that was replaced had. +## [br] +## The node that was replaced is queued to be freed. +## [br] +## TODO see replace_by method, this could simplify the logic here. +# ------------------------------------------------------------------------------ +func replace_node(base_node, path_or_node, with_this): + var path = path_or_node + + if(typeof(path_or_node) != TYPE_STRING): + # This will cause an engine error if it fails. It always returns a + # NodePath, even if it fails. Checking the name count is the only way + # I found to check if it found something or not (after it worked I + # didn't look any farther). + path = base_node.get_path_to(path_or_node) + if(path.get_name_count() == 0): + _lgr.error('You passed an object that base_node does not have. Cannot replace node.') + return + + if(!base_node.has_node(path)): + _lgr.error(str('Could not find node at path [', path, ']')) + return + + var to_replace = base_node.get_node(path) + var parent = to_replace.get_parent() + var replace_name = to_replace.get_name() + + parent.remove_child(to_replace) + parent.add_child(with_this) + with_this.set_name(replace_name) + with_this.set_owner(parent) + + var groups = to_replace.get_groups() + for i in range(groups.size()): + with_this.add_to_group(groups[i]) + + to_replace.queue_free() + + +## Use this as the default value for the first parameter to a test to create +## a parameterized test. See also the ParameterFactory and Parameterized Tests. +## [br][br] +## [b]Example[/b] +## [codeblock] +## func test_with_parameters(p = use_parameters([1, 2, 3])): +## [/codeblock] +func use_parameters(params): + var ph = gut.parameter_handler + if(ph == null): + ph = GutUtils.ParameterHandler.new(params) + gut.parameter_handler = ph + + # DO NOT use gut.gd's get_call_count_text here since it decrements the + # get_call_count value. This method increments the call count in its + # return statement. + var output = str('- params[', ph.get_call_count(), ']','(', ph.get_current_parameters(), ')') + gut.p(output, gut.LOG_LEVEL_TEST_AND_FAILURES) + + return ph.next_parameters() + + +## @internal +## When used as the default for a test method parameter, it will cause the test +## to be run x times. +## +## I Hacked this together to test a method that was occassionally failing due to +## timing issues. I don't think it's a great idea, but you be the judge. If +## you find a good use for it, let me know and I'll make it a legit member +## of the api. +func run_x_times(x): + var ph = gut.parameter_handler + if(ph == null): + _lgr.warn( + str("This test uses run_x_times and you really should not be ", + "using it. I don't think it's a good thing, but I did find it ", + "temporarily useful so I left it in here and didn't document it. ", + "Well, you found it, might as well open up an issue and let me ", + "know why you're doing this.")) + var params = [] + for i in range(x): + params.append(i) + + ph = GutUtils.ParameterHandler.new(params) + gut.parameter_handler = ph + return ph.next_parameters() + + +## Checks the passed in version string (x.x.x) against the engine version to see +## if the engine version is less than the expected version. If it is then the +## test is mareked as passed (for a lack of anything better to do). The result +## of the check is returned. +## [br][br] +## [b]Example[/b] +## [codeblock] +## if(skip_if_godot_version_lt('3.5.0')): +## return +## [/codeblock] +func skip_if_godot_version_lt(expected): + var should_skip = !GutUtils.is_godot_version_gte(expected) + if(should_skip): + _pass(str('Skipping: ', GutUtils.godot_version_string(), ' is less than ', expected)) + return should_skip + + +## Checks if the passed in version matches the engine version. The passed in +## version can contain just the major, major.minor or major.minor.path. If +## the version is not the same then the test is marked as passed. The result of +## the check is returned. +## [br][br] +## [b]Example[/b] +## [codeblock] +## if(skip_if_godot_version_ne('3.4')): +## return +## [/codeblock] +func skip_if_godot_version_ne(expected): + var should_skip = !GutUtils.is_godot_version(expected) + if(should_skip): + _pass(str('Skipping: ', GutUtils.godot_version_string(), ' is not ', expected)) + return should_skip + + +## Registers all the inner classes in a script with the doubler. This is required +## before you can double any inner class. +func register_inner_classes(base_script): + gut.get_doubler().inner_class_registry.register(base_script) + + +## Peforms a deep compare on both values, a CompareResult instnace is returned. +## The optional max_differences paramter sets the max_differences to be displayed. +func compare_deep(v1, v2, max_differences=null): + var result = _compare.deep(v1, v2) + if(max_differences != null): + result.max_differences = max_differences + return result + + +# ---------------- +#endregion +#region Asserts +# ---------------- + +## Asserts that the expected value equals the value got. +## assert got == expected and prints optional text. See [wiki]Comparing-Things[/wiki] +## for information about comparing dictionaries and arrays. +## [br] +## See also: [method assert_ne], [method assert_same], [method assert_not_same] +## [codeblock] +## var one = 1 +## var node1 = Node.new() +## var node2 = node1 +## +## # Passing +## assert_eq(one, 1, 'one should equal one') +## assert_eq('racecar', 'racecar') +## assert_eq(node2, node1) +## assert_eq([1, 2, 3], [1, 2, 3]) +## var d1_pass = {'a':1} +## var d2_pass = d1_pass +## assert_eq(d1_pass, d2_pass) +## +## # Failing +## assert_eq(1, 2) # FAIL +## assert_eq('hello', 'world') +## assert_eq(self, node1) +## assert_eq([1, 'two', 3], [1, 2, 3, 4]) +## assert_eq({'a':1}, {'a':1}) +## [/codeblock] +func assert_eq(got, expected, text=""): + + if(_do_datatypes_match__fail_if_not(got, expected, text)): + var disp = "[" + _str(got) + "] expected to equal [" + _str(expected) + "]: " + text + var result = null + + result = _compare.simple(got, expected) + + if(typeof(got) in [TYPE_ARRAY, TYPE_DICTIONARY]): + disp = str(result.summary, ' ', text) + _lgr.info('Array/Dictionary compared by value. Use assert_same to compare references. Use assert_eq_deep to see diff when failing.') + + if(result.are_equal): + _pass(disp) + else: + _fail(disp) + + +## asserts got != expected and prints optional text. See +## [wiki]Comparing-Things[/wiki] for information about comparing dictionaries +## and arrays. +##[br] +## See also: [method assert_eq], [method assert_same], [method assert_not_same] +## [codeblock] +## var two = 2 +## var node1 = Node.new() +## +## # Passing +## assert_ne(two, 1, 'Two should not equal one.') +## assert_ne('hello', 'world') +## assert_ne(self, node1) +## +## # Failing +## assert_ne(two, 2) +## assert_ne('one', 'one') +## assert_ne('2', 2) +## [/codeblock] +func assert_ne(got, not_expected, text=""): + if(_do_datatypes_match__fail_if_not(got, not_expected, text)): + var disp = "[" + _str(got) + "] expected to not equal [" + _str(not_expected) + "]: " + text + var result = null + + result = _compare.simple(got, not_expected) + + if(typeof(got) in [TYPE_ARRAY, TYPE_DICTIONARY]): + disp = str(result.summary, ' ', text) + _lgr.info('Array/Dictionary compared by value. Use assert_not_same to compare references. Use assert_ne_deep to see diff.') + + if(result.are_equal): + _fail(disp) + else: + _pass(disp) + + +## Asserts that [param got] is within the range of [param expected] +/- [param error_interval]. +## The upper and lower bounds are included in the check. Verified to work with +## integers, floats, and Vector2. Should work with anything that can be +## added/subtracted. +## +## [codeblock] +## # Passing +## assert_almost_eq(0, 1, 1, '0 within range of 1 +/- 1') +## assert_almost_eq(2, 1, 1, '2 within range of 1 +/- 1') +## assert_almost_eq(1.2, 1.0, .5, '1.2 within range of 1 +/- .5') +## assert_almost_eq(.5, 1.0, .5, '.5 within range of 1 +/- .5') +## assert_almost_eq(Vector2(.5, 1.5), Vector2(1.0, 1.0), Vector2(.5, .5)) +## assert_almost_eq(Vector2(.5, 1.5), Vector2(1.0, 1.0), Vector2(.25, .25)) +## +## # Failing +## assert_almost_eq(1, 3, 1, '1 outside range of 3 +/- 1') +## assert_almost_eq(2.6, 3.0, .2, '2.6 outside range of 3 +/- .2') +## [/codeblock] +func assert_almost_eq(got, expected, error_interval, text=''): + var disp = "[" + _str_precision(got, 20) + "] expected to equal [" + _str(expected) + "] +/- [" + str(error_interval) + "]: " + text + if(_do_datatypes_match__fail_if_not(got, expected, text) and _do_datatypes_match__fail_if_not(got, error_interval, text)): + if not _is_almost_eq(got, expected, error_interval): + _fail(disp) + else: + _pass(disp) + + +## This is the inverse of [method assert_almost_eq]. This will pass if [param got] is +## outside the range of [param not_expected] +/- [param error_interval]. +func assert_almost_ne(got, not_expected, error_interval, text=''): + var disp = "[" + _str_precision(got, 20) + "] expected to not equal [" + _str(not_expected) + "] +/- [" + str(error_interval) + "]: " + text + if(_do_datatypes_match__fail_if_not(got, not_expected, text) and _do_datatypes_match__fail_if_not(got, error_interval, text)): + if _is_almost_eq(got, not_expected, error_interval): + _fail(disp) + else: + _pass(disp) + +# ------------------------------------------------------------------------------ +# Helper function compares a value against a expected and a +/- range. Compares +# all components of Vector2, Vector3, and Vector4 as well. +# ------------------------------------------------------------------------------ +func _is_almost_eq(got, expected, error_interval) -> bool: + var result = false + var upper = expected + error_interval + var lower = expected - error_interval + + if typeof(got) in [TYPE_VECTOR2, TYPE_VECTOR3, TYPE_VECTOR4]: + result = got.clamp(lower, upper) == got + else: + result = got >= (lower) and got <= (upper) + + return(result) + +## assserts got > expected +## [codeblock] +## var bigger = 5 +## var smaller = 0 +## +## # Passing +## assert_gt(bigger, smaller, 'Bigger should be greater than smaller') +## assert_gt('b', 'a') +## assert_gt('a', 'A') +## assert_gt(1.1, 1) +## +## # Failing +## assert_gt('a', 'a') +## assert_gt(1.0, 1) +## assert_gt(smaller, bigger) +## [/codeblock] +func assert_gt(got, expected, text=""): + var disp = "[" + _str(got) + "] expected to be > than [" + _str(expected) + "]: " + text + if(_do_datatypes_match__fail_if_not(got, expected, text)): + if(got > expected): + _pass(disp) + else: + _fail(disp) + + +## Asserts got is greater than or equal to expected. +## [codeblock] +## var bigger = 5 +## var smaller = 0 +## +## # Passing +## assert_gte(bigger, smaller, 'Bigger should be greater than or equal to smaller') +## assert_gte('b', 'a') +## assert_gte('a', 'A') +## assert_gte(1.1, 1) +## assert_gte('a', 'a') +## +## # Failing +## assert_gte(0.9, 1.0) +## assert_gte(smaller, bigger) +## [/codeblock] +func assert_gte(got, expected, text=""): + var disp = "[" + _str(got) + "] expected to be >= than [" + _str(expected) + "]: " + text + if(_do_datatypes_match__fail_if_not(got, expected, text)): + if(got >= expected): + _pass(disp) + else: + _fail(disp) + +## Asserts [param got] is less than [param expected] +## [codeblock] +## var bigger = 5 +## var smaller = 0 +## +## # Passing +## assert_lt(smaller, bigger, 'Smaller should be less than bigger') +## assert_lt('a', 'b') +## assert_lt(99, 100) +## +## # Failing +## assert_lt('z', 'x') +## assert_lt(-5, -5) +## [/codeblock] +func assert_lt(got, expected, text=""): + var disp = "[" + _str(got) + "] expected to be < than [" + _str(expected) + "]: " + text + if(_do_datatypes_match__fail_if_not(got, expected, text)): + if(got < expected): + _pass(disp) + else: + _fail(disp) + + +## Asserts got is less than or equal to expected +func assert_lte(got, expected, text=""): + var disp = "[" + _str(got) + "] expected to be <= than [" + _str(expected) + "]: " + text + if(_do_datatypes_match__fail_if_not(got, expected, text)): + if(got <= expected): + _pass(disp) + else: + _fail(disp) + + +## asserts that got is true. Does not assert truthiness, only boolean values +## will pass. +func assert_true(got, text=""): + if(typeof(got) == TYPE_BOOL): + if(got): + _pass(text) + else: + _fail(text) + else: + var msg = str("Cannot convert ", _strutils.type2str(got), " to boolean") + _fail(msg) + + +## Asserts that got is false. Does not assert truthiness, only boolean values +## will pass. +func assert_false(got, text=""): + if(typeof(got) == TYPE_BOOL): + if(got): + _fail(text) + else: + _pass(text) + else: + var msg = str("Cannot convert ", _strutils.type2str(got), " to boolean") + _fail(msg) + + +## Asserts value is between (inclusive) the two expected values.[br] +## got >= expect_low and <= expect_high +## [codeblock] +## # Passing +## assert_between(5, 0, 10, 'Five should be between 0 and 10') +## assert_between(10, 0, 10) +## assert_between(0, 0, 10) +## assert_between(2.25, 2, 4.0) +## +## # Failing +## assert_between('a', 'b', 'c') +## assert_between(1, 5, 10) +## [/codeblock] +func assert_between(got, expect_low, expect_high, text=""): + var disp = "[" + _str_precision(got, 20) + "] expected to be between [" + _str(expect_low) + "] and [" + str(expect_high) + "]: " + text + + if(_do_datatypes_match__fail_if_not(got, expect_low, text) and _do_datatypes_match__fail_if_not(got, expect_high, text)): + if(expect_low > expect_high): + disp = "INVALID range. [" + str(expect_low) + "] is not less than [" + str(expect_high) + "]" + _fail(disp) + else: + if(got < expect_low or got > expect_high): + _fail(disp) + else: + _pass(disp) + + +## Asserts value is not between (exclusive) the two expected values.[br] +## asserts that got <= expect_low or got >= expect_high. +## [codeblock] +## # Passing +## assert_not_between(1, 5, 10) +## assert_not_between('a', 'b', 'd') +## assert_not_between('d', 'b', 'd') +## assert_not_between(10, 0, 10) +## assert_not_between(-2, -2, 10) +## +## # Failing +## assert_not_between(5, 0, 10, 'Five shouldnt be between 0 and 10') +## assert_not_between(0.25, -2.0, 4.0) +## [/codeblock] +func assert_not_between(got, expect_low, expect_high, text=""): + var disp = "[" + _str_precision(got, 20) + "] expected not to be between [" + _str(expect_low) + "] and [" + str(expect_high) + "]: " + text + + if(_do_datatypes_match__fail_if_not(got, expect_low, text) and _do_datatypes_match__fail_if_not(got, expect_high, text)): + if(expect_low > expect_high): + disp = "INVALID range. [" + str(expect_low) + "] is not less than [" + str(expect_high) + "]" + _fail(disp) + else: + if(got > expect_low and got < expect_high): + _fail(disp) + else: + _pass(disp) + + +## Uses the 'has' method of the object passed in to determine if it contains +## the passed in element. +## [codeblock] +## var an_array = [1, 2, 3, 'four', 'five'] +## var a_hash = { 'one':1, 'two':2, '3':'three'} +## +## # Passing +## assert_has(an_array, 'four') # PASS +## assert_has(an_array, 2) # PASS +## # the hash's has method checks indexes not values +## assert_has(a_hash, 'one') # PASS +## assert_has(a_hash, '3') # PASS +## +## # Failing +## assert_has(an_array, 5) # FAIL +## assert_has(an_array, self) # FAIL +## assert_has(a_hash, 3) # FAIL +## assert_has(a_hash, 'three') # FAIL +## [/codeblock] +func assert_has(obj, element, text=""): + var disp = str('Expected [', _str(obj), '] to contain value: [', _str(element), ']: ', text) + if(obj.has(element)): + _pass(disp) + else: + _fail(disp) + + +## The inverse of assert_has. +func assert_does_not_have(obj, element, text=""): + var disp = str('Expected [', _str(obj), '] to NOT contain value: [', _str(element), ']: ', text) + if(obj.has(element)): + _fail(disp) + else: + _pass(disp) + + +## asserts a file exists at the specified path +## [codeblock] +## func before_each(): +## gut.file_touch('user://some_test_file') +## +## func after_each(): +## gut.file_delete('user://some_test_file') +## +## func test_assert_file_exists(): +## # Passing +## assert_file_exists('res://addons/gut/gut.gd') +## assert_file_exists('user://some_test_file') +## +## # Failing +## assert_file_exists('user://file_does_not.exist') +## assert_file_exists('res://some_dir/another_dir/file_does_not.exist') +## [/codeblock] +func assert_file_exists(file_path): + var disp = 'expected [' + file_path + '] to exist.' + if(FileAccess.file_exists(file_path)): + _pass(disp) + else: + _fail(disp) + + +## asserts a file does not exist at the specified path +## [codeblock] +## func before_each(): +## gut.file_touch('user://some_test_file') +## +## func after_each(): +## gut.file_delete('user://some_test_file') +## +## func test_assert_file_does_not_exist(): +## # Passing +## assert_file_does_not_exist('user://file_does_not.exist') +## assert_file_does_not_exist('res://some_dir/another_dir/file_does_not.exist') +## +## # Failing +## assert_file_does_not_exist('res://addons/gut/gut.gd') +## [/codeblock] +func assert_file_does_not_exist(file_path): + var disp = 'expected [' + file_path + '] to NOT exist' + if(!FileAccess.file_exists(file_path)): + _pass(disp) + else: + _fail(disp) + + +## asserts the specified file is empty +## [codeblock] +## func before_each(): +## gut.file_touch('user://some_test_file') +## +## func after_each(): +## gut.file_delete('user://some_test_file') +## +## func test_assert_file_empty(): +## # Passing +## assert_file_empty('user://some_test_file') +## +## # Failing +## assert_file_empty('res://addons/gut/gut.gd') +## [/codeblock] +func assert_file_empty(file_path): + var disp = 'expected [' + file_path + '] to be empty' + if(FileAccess.file_exists(file_path) and gut.is_file_empty(file_path)): + _pass(disp) + else: + _fail(disp) + + +## Asserts the specified file is not empty +## [codeblock] +## func before_each(): +## gut.file_touch('user://some_test_file') +## +## func after_each(): +## gut.file_delete('user://some_test_file') +## +## func test_assert_file_not_empty(): +## # Passing +## assert_file_not_empty('res://addons/gut/gut.gd') # PASS +## +## # Failing +## assert_file_not_empty('user://some_test_file') # FAIL +## [/codeblock] +func assert_file_not_empty(file_path): + var disp = 'expected [' + file_path + '] to contain data' + if(!gut.is_file_empty(file_path)): + _pass(disp) + else: + _fail(disp) + + +## Asserts that the passed in object has a method named [param method]. +func assert_has_method(obj, method, text=''): + var disp = _str(obj) + ' should have method: ' + method + if(text != ''): + disp = _str(obj) + ' ' + text + assert_true(obj.has_method(method), disp) + + +## This is meant to make testing public get/set methods for a member variable. This was originally created for early Godot 3.x setter and getter methods. See [method assert_property] for verifying Godot 4.x accessors. This makes multiple assertions to verify: +## [br] +## [li]The object has a method called [code]get_[/code][/li] +## [li]The object has a method called [code]set_[/code][/li] +## [li]The method [code]get_[/code] returns the expected default value when first called.[/li] +## [li]Once you set the property, the [code]get_[/code] returns the new value.[/li] +## [br] +func assert_accessors(obj, property, default, set_to): + var fail_count = _summary.failed + var get_func = 'get_' + property + var set_func = 'set_' + property + + if(obj.has_method('is_' + property)): + get_func = 'is_' + property + + assert_has_method(obj, get_func, 'should have getter starting with get_ or is_') + assert_has_method(obj, set_func) + # SHORT CIRCUIT + if(_summary.failed > fail_count): + return + assert_eq(obj.call(get_func), default, 'It should have the expected default value.') + obj.call(set_func, set_to) + assert_eq(obj.call(get_func), set_to, 'The set value should have been returned.') + + +# Property search helper. Used to retrieve Dictionary of specified property +# from passed object. Returns null if not found. +# If provided, property_usage constrains the type of property returned by +# passing either: +# EDITOR_PROPERTY for properties defined as: export var some_value: int +# VARIABLE_PROPERTY for properties defined as: var another_value +func _find_object_property(obj, property_name, property_usage=null): + var result = null + var found = false + var properties = obj.get_property_list() + + while !found and !properties.is_empty(): + var property = properties.pop_back() + if property['name'] == property_name: + if property_usage == null or property['usage'] == property_usage: + result = property + found = true + return result + + +## Asserts that [param obj] exports a property with the name +## [param property_name] and a type of [param type]. The [param type] must be +## one of the various Godot built-in [code]TYPE_[/code] constants. +## [codeblock] +## class ExportClass: +## export var some_number = 5 +## export(PackedScene) var some_scene +## var some_variable = 1 +## +## func test_assert_exports(): +## var obj = ExportClass.new() +## +## # Passing +## assert_exports(obj, "some_number", TYPE_INT) +## assert_exports(obj, "some_scene", TYPE_OBJECT) +## +## # Failing +## assert_exports(obj, 'some_number', TYPE_VECTOR2) +## assert_exports(obj, 'some_scene', TYPE_AABB) +## assert_exports(obj, 'some_variable', TYPE_INT) +## [/codeblock] +func assert_exports(obj, property_name, type): + var disp = 'expected %s to have editor property [%s]' % [_str(obj), property_name] + var property = _find_object_property(obj, property_name, EDITOR_PROPERTY) + if property != null: + disp += ' of type [%s]. Got type [%s].' % [_strutils.types[type], _strutils.types[property['type']]] + if property['type'] == type: + _pass(disp) + else: + _fail(disp) + else: + _fail(disp) + + +# Signal assertion helper. +# +# Verifies that the object and signal are valid for making signal assertions. +# This will fail with specific messages that indicate why they are not valid. +# This returns true/false to indicate if the object and signal are valid. +func _can_make_signal_assertions(object, signal_name): + return !(_fail_if_not_watching(object) or _fail_if_does_not_have_signal(object, signal_name)) + + +# Check if an object is connected to a signal on another object. Returns True +# if it is and false otherwise +func _is_connected(signaler_obj, connect_to_obj, signal_name, method_name=""): + if(method_name != ""): + return signaler_obj.is_connected(signal_name,Callable(connect_to_obj,method_name)) + else: + var connections = signaler_obj.get_signal_connection_list(signal_name) + for conn in connections: + if(conn['signal'].get_name() == signal_name and conn['callable'].get_object() == connect_to_obj): + return true + return false + + +## Asserts that `signaler_obj` is connected to `connect_to_obj` on signal `signal_name`. The method that is connected is optional. If `method_name` is supplied then this will pass only if the signal is connected to the method. If it is not provided then any connection to the signal will cause a pass. +## [br][br] +## [b]Signatures:[/b][br] +## - assert_connected([param p1]:Signal, [param p2]:connected-object)[br] +## - assert_connected([param p1]:Signal, [param p2]:connected-method)[br] +## - assert_connected([param p1]:object, [param p2]:connected-object, [param p3]:signal-name, [param p4]: connected-method-name ) +## [br][br] +## [b]Examples:[/b] +## [codeblock] +## class Signaler: +## signal the_signal +## +## class Connector: +## func connect_this(): +## pass +## func other_method(): +## pass +## +## func test_assert_connected(): +## var signaler = Signaler.new() +## var connector = Connector.new() +## signaler.the_signal.connect(connector.connect_this) +## +## # Passing +## assert_connected(signaler.the_signal, connector.connect_this) +## assert_connected(signaler.the_signal, connector) +## assert_connected(signaler, connector, 'the_signal') +## assert_connected(signaler, connector, 'the_signal', 'connect_this') +## +## # Failing +## assert_connected(signaler.the_signal, connector.other_method) +## +## var foo = Connector.new() +## assert_connected(signaler, connector, 'the_signal', 'other_method') +## assert_connected(signaler, connector, 'other_signal') +## assert_connected(signaler, foo, 'the_signal') +## [/codeblock] +func assert_connected(p1, p2, p3=null, p4=""): + var sp := SignalAssertParameters.new(p1, p3) + var connect_to_obj = p2 + var method_name = p4 + + if(connect_to_obj is Callable): + method_name = connect_to_obj.get_method() + connect_to_obj = connect_to_obj.get_object() + + var method_disp = '' + if (method_name != ""): + method_disp = str(' using method: [', method_name, '] ') + var disp = str('Expected object ', _str(sp.object),\ + ' to be connected to signal: [', sp.signal_name, '] on ',\ + _str(connect_to_obj), method_disp) + if(_is_connected(sp.object, connect_to_obj, sp.signal_name, method_name)): + _pass(disp) + else: + _fail(disp) + + +## The inverse of [method assert_connected]. See [method assert_connected] for parameter syntax. +## [br] +## This will fail with specific messages if the target object is connected to the specified signal on the source object. +func assert_not_connected(p1, p2, p3=null, p4=""): + var sp := SignalAssertParameters.new(p1, p3) + var connect_to_obj = p2 + var method_name = p4 + + if(connect_to_obj is Callable): + method_name = connect_to_obj.get_method() + connect_to_obj = connect_to_obj.get_object() + + var method_disp = '' + if (method_name != ""): + method_disp = str(' using method: [', method_name, '] ') + var disp = str('Expected object ', _str(sp.object),\ + ' to not be connected to signal: [', sp.signal_name, '] on ',\ + _str(sp.object), method_disp) + if(_is_connected(sp.object, connect_to_obj, sp.signal_name, method_name)): + _fail(disp) + else: + _pass(disp) + + +## Assert that the specified object emitted the named signal. You must call +## [method watch_signals] and pass it the object that you are making assertions about. +## This will fail if the object is not being watched or if the object does not +## have the specified signal. Since this will fail if the signal does not +## exist, you can often skip using [method assert_has_signal]. +## [br][br] +## [b]Signatures:[/b][br] +## - assert_signal_emitted([param p1]:Signal, [param p2]: text )[br] +## - assert_signal_emitted([param p1]:object, [param p2]:signal-name, [param p3]: text ) +## [br][br] +## [b]Examples:[/b] +## [codeblock] +## class SignalObject: +## signal some_signal +## signal other_signal +## +## +## func test_assert_signal_emitted(): +## var obj = SignalObject.new() +## +## watch_signals(obj) +## obj.emit_signal('some_signal') +## +## ## Passing +## assert_signal_emitted(obj, 'some_signal') +## assert_signal_emitted(obj.some_signal) +## +## ## Failing +## # Fails with specific message that the object does not have the signal +## assert_signal_emitted(obj, 'signal_does_not_exist') +## # Fails because the object passed is not being watched +## assert_signal_emitted(SignalObject.new(), 'some_signal') +## # Fails because the signal was not emitted +## assert_signal_emitted(obj, 'other_signal') +## assert_signal_emitted(obj.other_signal) +## [/codeblock] +func assert_signal_emitted(p1, p2='', p3=""): + var sp := SignalAssertParameters.new(p1, p2, p3) + var disp = str('Expected object ', _str(sp.object), ' to have emitted signal [', sp.signal_name, ']: ', sp.others[0]) + if(_can_make_signal_assertions(sp.object, sp.signal_name)): + if(_signal_watcher.did_emit(sp.object, sp.signal_name)): + _pass(disp) + else: + _fail(_get_fail_msg_including_emitted_signals(disp, sp.object)) + + +## This works opposite of `assert_signal_emitted`. This will fail if the object +## is not being watched or if the object does not have the signal. +## [br][br] +## [b]Signatures:[/b][br] +## - assert_signal_not_emitted([param p1]:Signal, [param p2]: text )[br] +## - assert_signal_not_emitted([param p1]:object, [param p2]:signal-name, [param p3]: text ) +## [br][br] +## [b]Examples:[/b] +## [codeblock] +## class SignalObject: +## signal some_signal +## signal other_signal +## +## func test_assert_signal_not_emitted(): +## var obj = SignalObject.new() +## +## watch_signals(obj) +## obj.emit_signal('some_signal') +## +## # Passing +## assert_signal_not_emitted(obj, 'other_signal') +## assert_signal_not_emitted(obj.other_signal) +## +## # Failing +## # Fails with specific message that the object does not have the signal +## assert_signal_not_emitted(obj, 'signal_does_not_exist') +## # Fails because the object passed is not being watched +## assert_signal_not_emitted(SignalObject.new(), 'some_signal') +## # Fails because the signal was emitted +## assert_signal_not_emitted(obj, 'some_signal') +## [/codeblock] +func assert_signal_not_emitted(p1, p2='', p3=''): + var sp := SignalAssertParameters.new(p1, p2, p3) + var disp = str('Expected object ', _str(sp.object), ' to NOT emit signal [', sp.signal_name, ']: ', sp.others[0]) + if(_can_make_signal_assertions(sp.object, sp.signal_name)): + if(_signal_watcher.did_emit(sp.object, sp.signal_name)): + _fail(disp) + else: + _pass(disp) + + +## Asserts that a signal was fired with the specified parameters. The expected +## parameters should be passed in as an array. An optional index can be passed +## when a signal has fired more than once. The default is to retrieve the most +## recent emission of the signal. +## [br] +## This will fail with specific messages if the object is not being watched or +## the object does not have the specified signal +## [br][br] +## [b]Signatures:[/b][br] +## - assert_signal_emitted_with_parameters([param p1]:Signal, [param p2]:expected-parameters, [param p3]: index )[br] +## - assert_signal_emitted_with_parameters([param p1]:object, [param p2]:signal-name, [param p3]:expected-parameters, [param p4]: index ) +## [br][br] +## [b]Examples:[/b] +## [codeblock] +## class SignalObject: +## signal some_signal +## signal other_signal +## +## func test_assert_signal_emitted_with_parameters(): +## var obj = SignalObject.new() +## +## watch_signals(obj) +## # emit the signal 3 times to illustrate how the index works in +## # assert_signal_emitted_with_parameters +## obj.emit_signal('some_signal', 1, 2, 3) +## obj.emit_signal('some_signal', 'a', 'b', 'c') +## obj.emit_signal('some_signal', 'one', 'two', 'three') +## +## # Passing +## # Passes b/c the default parameters to check are the last emission of +## # the signal +## assert_signal_emitted_with_parameters(obj, 'some_signal', ['one', 'two', 'three']) +## assert_signal_emitted_with_parameters(obj.some_signal, ['one', 'two', 'three']) +## +## # Passes because the parameters match the specified emission based on index. +## assert_signal_emitted_with_parameters(obj, 'some_signal', [1, 2, 3], 0) +## assert_signal_emitted_with_parameters(obj.some_signal, [1, 2, 3], 0) +## +## # Failing +## # Fails with specific message that the object does not have the signal +## assert_signal_emitted_with_parameters(obj, 'signal_does_not_exist', []) +## # Fails because the object passed is not being watched +## assert_signal_emitted_with_parameters(SignalObject.new(), 'some_signal', []) +## # Fails because parameters do not match latest emission +## assert_signal_emitted_with_parameters(obj, 'some_signal', [1, 2, 3]) +## # Fails because the parameters for the specified index do not match +## assert_signal_emitted_with_parameters(obj, 'some_signal', [1, 2, 3], 1) +## [/codeblock] +func assert_signal_emitted_with_parameters(p1, p2, p3=-1, p4=-1): + var sp := SignalAssertParameters.new(p1, p2, p3, p4) + var parameters = sp.others[0] + var index = sp.others[1] + + if(typeof(parameters) != TYPE_ARRAY): + _lgr.error("The expected parameters must be wrapped in an array, you passed: " + _str(parameters)) + _fail("Bad Parameters") + return + + var disp = str('Expected object ', _str(sp.object), ' to emit signal [', sp.signal_name, '] with parameters ', parameters, ', got ') + if(_can_make_signal_assertions(sp.object, sp.signal_name)): + if(_signal_watcher.did_emit(sp.object, sp.signal_name)): + var parms_got = _signal_watcher.get_signal_parameters(sp.object, sp.signal_name, index) + var diff_result = _compare.deep(parameters, parms_got) + if(diff_result.are_equal): + _pass(str(disp, parms_got)) + else: + _fail(str('Expected object ', _str(sp.object), ' to emit signal [', sp.signal_name, '] with parameters ', diff_result.summarize())) + else: + var text = str('Object ', sp.object, ' did not emit signal [', sp.signal_name, ']') + _fail(_get_fail_msg_including_emitted_signals(text, sp.object)) + + +## Asserts that a signal fired a specific number of times. +## [br][br] +## [b]Signatures:[/b][br] +## - assert_signal_emit_count([param p1]:Signal, [param p2]:expected-count, [param p3]: text )[br] +## - assert_signal_emit_count([param p1]:object, [param p2]:signal-name, [param p3]:expected-count, [param p4]: text ) +## [br][br] +## [b]Examples:[/b] +## [codeblock] +## class SignalObject: +## signal some_signal +## signal other_signal +## +## +## func test_assert_signal_emit_count(): +## var obj_a = SignalObject.new() +## var obj_b = SignalObject.new() +## +## watch_signals(obj_a) +## watch_signals(obj_b) +## +## obj_a.emit_signal('some_signal') +## obj_a.emit_signal('some_signal') +## +## obj_b.emit_signal('some_signal') +## obj_b.emit_signal('other_signal') +## +## # Passing +## assert_signal_emit_count(obj_a, 'some_signal', 2, 'passes') +## assert_signal_emit_count(obj_a.some_signal, 2, 'passes') +## +## assert_signal_emit_count(obj_a, 'other_signal', 0) +## assert_signal_emit_count(obj_a.other_signal, 0) +## +## assert_signal_emit_count(obj_b, 'other_signal', 1) +## +## # Failing +## # Fails with specific message that the object does not have the signal +## assert_signal_emit_count(obj_a, 'signal_does_not_exist', 99) +## # Fails because the object passed is not being watched +## assert_signal_emit_count(SignalObject.new(), 'some_signal', 99) +## # The following fail for obvious reasons +## assert_signal_emit_count(obj_a, 'some_signal', 0) +## assert_signal_emit_count(obj_b, 'other_signal', 283) +## [/codeblock] +func assert_signal_emit_count(p1, p2, p3=0, p4=""): + var sp := SignalAssertParameters.new(p1, p2, p3, p4) + var times = sp.others[0] + var text = sp.others[1] + + if(_can_make_signal_assertions(sp.object, sp.signal_name)): + var count = _signal_watcher.get_emit_count(sp.object, sp.signal_name) + var disp = str('Expected the signal [', sp.signal_name, '] emit count of [', count, '] to equal [', times, ']: ', text) + if(count== times): + _pass(disp) + else: + _fail(_get_fail_msg_including_emitted_signals(disp, sp.object)) + + +## Asserts the passed in object has a signal with the specified name. It +## should be noted that all the asserts that verify a signal was/wasn't emitted +## will first check that the object has the signal being asserted against. If +## it does not, a specific failure message will be given. This means you can +## usually skip the step of specifically verifying that the object has a signal +## and move on to making sure it emits the signal correctly. +## [codeblock] +## class SignalObject: +## signal some_signal +## signal other_signal +## +## func test_assert_has_signal(): +## var obj = SignalObject.new() +## +## ## Passing +## assert_has_signal(obj, 'some_signal') +## assert_has_signal(obj, 'other_signal') +## +## ## Failing +## assert_has_signal(obj, 'not_a real SIGNAL') +## assert_has_signal(obj, 'yea, this one doesnt exist either') +## # Fails because the signal is not a user signal. Node2D does have the +## # specified signal but it can't be checked this way. It could be watched +## # and asserted that it fired though. +## assert_has_signal(Node2D.new(), 'exit_tree') +## [/codeblock] +func assert_has_signal(object, signal_name, text=""): + var disp = str('Expected object ', _str(object), ' to have signal [', signal_name, ']: ', text) + if(_signal_watcher.does_object_have_signal(object, signal_name)): + _pass(disp) + else: + _fail(disp) + + +## Asserts that [param object] extends [param a_class]. object must be an instance of an +## object. It cannot be any of the built in classes like Array or Int or Float. +## [param a_class] must be a class, it can be loaded via load, a GDNative class such as +## Node or Label or anything else. +## [codeblock] +## # Passing +## assert_is(Node2D.new(), Node2D) +## assert_is(Label.new(), CanvasItem) +## assert_is(SubClass.new(), BaseClass) +## # Since this is a test script that inherits from test.gd, so +## # this passes. It's not obvious w/o seeing the whole script +## # so I'm telling you. You'll just have to trust me. +## assert_is(self, load('res://addons/gut/test.gd')) +## +## var Gut = load('res://addons/gut/gut.gd') +## var a_gut = Gut.new() +## assert_is(a_gut, Gut) +## +## # Failing +## assert_is(Node2D.new(), Node2D.new()) +## assert_is(BaseClass.new(), SubClass) +## assert_is('a', 'b') +## assert_is([], Node) +## [/codeblock] +func assert_is(object, a_class, text=''): + var disp = ''#var disp = str('Expected [', _str(object), '] to be type of [', a_class, ']: ', text) + var bad_param_2 = 'Parameter 2 must be a Class (like Node2D or Label). You passed ' + + if(typeof(object) != TYPE_OBJECT): + _fail(str('Parameter 1 must be an instance of an object. You passed: ', _str(object))) + elif(typeof(a_class) != TYPE_OBJECT): + _fail(str(bad_param_2, _str(a_class))) + else: + var a_str = _str(a_class) + disp = str('Expected [', _str(object), '] to extend [', a_str, ']: ', text) + if(!GutUtils.is_native_class(a_class) and !GutUtils.is_gdscript(a_class)): + _fail(str(bad_param_2, a_str)) + else: + if(is_instance_of(object, a_class)): + _pass(disp) + else: + _fail(disp) + + +## Asserts that [param object] is the the [param type] specified. [param type] +## should be one of the Godot [code]TYPE_[/code] constants. +## [codeblock] +## # Passing +## var c = Color(1, 1, 1, 1) +## gr.test.assert_typeof(c, TYPE_COLOR) +## assert_pass(gr.test) +## +## # Failing +## gr.test.assert_typeof('some string', TYPE_INT) +## assert_fail(gr.test) +## [/codeblock] +func assert_typeof(object, type, text=''): + var disp = str('Expected [typeof(', object, ') = ') + disp += _get_typeof_string(typeof(object)) + disp += '] to equal [' + disp += _get_typeof_string(type) + ']' + disp += '. ' + text + if(typeof(object) == type): + _pass(disp) + else: + _fail(disp) + + +## The inverse of [method assert_typeof] +func assert_not_typeof(object, type, text=''): + var disp = str('Expected [typeof(', object, ') = ') + disp += _get_typeof_string(typeof(object)) + disp += '] to not equal [' + disp += _get_typeof_string(type) + ']' + disp += '. ' + text + if(typeof(object) != type): + _pass(disp) + else: + _fail(disp) + + +## Assert that `text` contains `search`. Can perform case insensitive search +## by passing false for `match_case`. +## [codeblock] +## # Passing +## assert_string_contains('abc 123', 'a') +## assert_string_contains('abc 123', 'BC', false) +## assert_string_contains('abc 123', '3') +## +## # Failing +## assert_string_contains('abc 123', 'A') +## assert_string_contains('abc 123', 'BC') +## assert_string_contains('abc 123', '012') +## [/codeblock] +func assert_string_contains(text, search, match_case=true): + const empty_search = 'Expected text and search strings to be non-empty. You passed %s and %s.' + const non_strings = 'Expected text and search to both be strings. You passed %s and %s.' + var disp = 'Expected \'%s\' to contain \'%s\', match_case=%s' % [text, search, match_case] + if(typeof(text) != TYPE_STRING or typeof(search) != TYPE_STRING): + _fail(non_strings % [_str(text), _str(search)]) + elif(text == '' or search == ''): + _fail(empty_search % [_str(text), _str(search)]) + elif(match_case): + if(text.find(search) == -1): + _fail(disp) + else: + _pass(disp) + else: + if(text.to_lower().find(search.to_lower()) == -1): + _fail(disp) + else: + _pass(disp) + + +## Assert that text starts with search. Can perform case insensitive check +## by passing false for match_case +## [codeblock] +## # Passing +## assert_string_starts_with('abc 123', 'a') +## assert_string_starts_with('abc 123', 'ABC', false) +## assert_string_starts_with('abc 123', 'abc 123') +## +## ## Failing +## assert_string_starts_with('abc 123', 'z') +## assert_string_starts_with('abc 123', 'ABC') +## assert_string_starts_with('abc 123', 'abc 1234') +## [/codeblock] +func assert_string_starts_with(text, search, match_case=true): + var empty_search = 'Expected text and search strings to be non-empty. You passed \'%s\' and \'%s\'.' + var disp = 'Expected \'%s\' to start with \'%s\', match_case=%s' % [text, search, match_case] + if(text == '' or search == ''): + _fail(empty_search % [text, search]) + elif(match_case): + if(text.find(search) == 0): + _pass(disp) + else: + _fail(disp) + else: + if(text.to_lower().find(search.to_lower()) == 0): + _pass(disp) + else: + _fail(disp) + + +## Assert that [param text] ends with [param search]. Can perform case insensitive check by passing false for [param match_case] +## [codeblock] +## ## Passing +## assert_string_ends_with('abc 123', '123') +## assert_string_ends_with('abc 123', 'C 123', false) +## assert_string_ends_with('abc 123', 'abc 123') +## +## ## Failing +## assert_string_ends_with('abc 123', '1234') +## assert_string_ends_with('abc 123', 'C 123') +## assert_string_ends_with('abc 123', 'nope') +## [/codeblock] +func assert_string_ends_with(text, search, match_case=true): + var empty_search = 'Expected text and search strings to be non-empty. You passed \'%s\' and \'%s\'.' + var disp = 'Expected \'%s\' to end with \'%s\', match_case=%s' % [text, search, match_case] + var required_index = len(text) - len(search) + if(text == '' or search == ''): + _fail(empty_search % [text, search]) + elif(match_case): + if(text.find(search) == required_index): + _pass(disp) + else: + _fail(disp) + else: + if(text.to_lower().find(search.to_lower()) == required_index): + _pass(disp) + else: + _fail(disp) + + +# ------------------------------------------------------------------------------ +## Assert that a method was called on an instance of a doubled class. If +## parameters are supplied then the params passed in when called must match. +## +## Can be called with a Callabe instead of specifying the object, method_name, +## and parameters. The Callable's object must be a double. Bound arguments +## will be used to match calls based on values passed to the method. +## [br] +## See also: [wiki]Doubles[/wiki], [wiki]Spies[/wiki] +## [br][br] +## [b]Examples[/b] +## [codeblock] +## var my_double = double(Foobar).new() +## ... +## assert_called(my_double, 'foo') +## assert_called(my_double.foo) +## assert_called(my_double, 'foo', [1, 2, 3]) +## assert_called(my_double.foo.bind(1, 2, 3)) +## [/codeblock] +func assert_called(inst, method_name=null, parameters=null): + + if(_fail_if_parameters_not_array(parameters)): + return + + var converted = _convert_spy_args(inst, method_name, parameters) + if(converted.invalid_message != 'ok'): + fail_test(converted.invalid_message) + return + + var disp = str('Expected [',converted.method_name,'] to have been called on ',_str(converted.object)) + if(converted.arguments != null): + disp += str(' with parameters ', converted.arguments) + + if(_fail_if_not_double_or_does_not_have_method(converted.object, converted.method_name) == OK): + if(gut.get_spy().was_called( + converted.object, converted.method_name, converted.arguments)): + _pass(disp) + else: + _fail(str(disp, "\n", _get_desc_of_calls_to_instance(converted.object))) + + +# ------------------------------------------------------------------------------ +## Assert that a method was not called on an instance of a doubled class. If +## parameters are specified then this will only fail if it finds a call that was +## sent matching parameters. +## +## Can be called with a Callabe instead of specifying the object, method_name, +## and parameters. The Callable's object must be a double. Bound arguments +## will be used to match calls based on values passed to the method. +## [br] +## See also: [wiki]Doubles[/wiki], [wiki]Spies[/wiki] +## [br][br] +## [b]Examples[/b] +## [codeblock] +## assert_not_called(my_double, 'foo') +## assert_not_called(my_double.foo) +## assert_not_called(my_double, 'foo', [1, 2, 3]) +## assert_not_called(my_double.foo.bind(1, 2, 3)) +## [/codeblock] +func assert_not_called(inst, method_name=null, parameters=null): + + if(_fail_if_parameters_not_array(parameters)): + return + + var converted = _convert_spy_args(inst, method_name, parameters) + if(converted.invalid_message != 'ok'): + fail_test(converted.invalid_message) + return + + var disp = str('Expected [', converted.method_name, '] to NOT have been called on ', _str(converted.object)) + + if(_fail_if_not_double_or_does_not_have_method(converted.object, converted.method_name) == OK): + if(gut.get_spy().was_called( + converted.object, converted.method_name, converted.arguments)): + if(converted.arguments != null): + disp += str(' with parameters ', converted.arguments) + _fail(str(disp, "\n", _get_desc_of_calls_to_instance(converted.object))) + else: + _pass(disp) + + +## Asserts the the method of a double was called an expected number of times. +## If any arguments are bound to the callable then only calls with matching +## arguments will be counted. +## [br] +## See also: [wiki]Doubles[/wiki], [wiki]Spies[/wiki] +## [br][br] +## [b]Examples[/b] +## [codeblock] +## # assert foo was called on my_double 5 times +## assert_called_count(my_double.foo, 5) +## # assert foo, with parameters [1,2,3], was called on my_double 4 times. +## assert_called_count(my_double.foo.bind(1, 2, 3), 4) +## [/codeblock] +func assert_called_count(callable : Callable, expected_count : int): + var converted = _convert_spy_args(callable, null, null) + var count = gut.get_spy().call_count(converted.object, converted.method_name, converted.arguments) + + var param_text = '' + if(callable.get_bound_arguments_count() > 0): + param_text = ' with parameters ' + str(callable.get_bound_arguments()) + var disp = 'Expected [%s] on %s to be called [%s] times%s. It was called [%s] times.' + disp = disp % [converted.method_name, _str(converted.object), expected_count, param_text, count] + + + if(_fail_if_not_double_or_does_not_have_method(converted.object, converted.method_name) == OK): + if(count == expected_count): + _pass(disp) + else: + _fail(str(disp, "\n", _get_desc_of_calls_to_instance(converted.object))) + + +## Asserts the passed in value is null +func assert_null(got, text=''): + var disp = str('Expected [', _str(got), '] to be NULL: ', text) + if(got == null): + _pass(disp) + else: + _fail(disp) + + +## Asserts the passed in value is not null. +func assert_not_null(got, text=''): + var disp = str('Expected [', _str(got), '] to be anything but NULL: ', text) + if(got == null): + _fail(disp) + else: + _pass(disp) + + +## Asserts that the passed in object has been freed. This assertion requires +## that you pass in some text in the form of a title since, if the object is +## freed, we won't have anything to convert to a string to put in the output +## statement. +## [br] +## [b]Note[/b] that this currently does not detect if a node has been queued free. +## [codeblock] +## var obj = Node.new() +## obj.free() +## test.assert_freed(obj, "New Node") +## [/codeblock] +func assert_freed(obj, title='something'): + var disp = title + if(is_instance_valid(obj)): + disp = _strutils.type2str(obj) + title + assert_true(not is_instance_valid(obj), "Expected [%s] to be freed" % disp) + + +## The inverse of [method assert_freed] +func assert_not_freed(obj, title='something'): + var disp = title + if(is_instance_valid(obj)): + disp = _strutils.type2str(obj) + title + assert_true(is_instance_valid(obj), "Expected [%s] to not be freed" % disp) + + +## This method will assert that no orphaned nodes have been introduced by the +## test when the assert is executed. See the [wiki]Memory-Management[/wiki] +## page for more information. +func assert_no_new_orphans(text=''): + var orphan_ids = gut.get_current_test_orphans() + var count = orphan_ids.size() + var msg = '' + if(text != ''): + msg = ': ' + text + # Note that get_counter will return -1 if the counter does not exist. This + # can happen with a misplaced assert_no_new_orphans. Checking for > 0 + # ensures this will not cause some weird failure. + if(count > 0): + msg += str("\n", _strutils.indent_text(gut.get_orphan_counter().get_orphan_list_text(orphan_ids), 1, ' ')) + _fail(str('Expected no orphans, but found ', count, msg)) + else: + _pass('No new orphans found.' + msg) + + +## @ignore +func assert_set_property(obj, property_name, new_value, expected_value): + pending("this hasn't been implemented yet") + + +## @ignore +func assert_readonly_property(obj, property_name, new_value, expected_value): + pending("this hasn't been implemented yet") + + +## Assumes backing varible with be _. This will perform all the +## asserts of assert_property. Then this will set the value through the setter +## and check the backing variable value. It will then reset throught the setter +## and set the backing variable and check the getter. +func assert_property_with_backing_variable(obj, property_name, default_value, new_value, backed_by_name=null): + var setter_name = str('@', property_name, '_setter') + var getter_name = str('@', property_name, '_getter') + var backing_name = GutUtils.nvl(backed_by_name, str('_', property_name)) + var pre_fail_count = get_fail_count() + + var props = obj.get_property_list() + var found = false + var idx = 0 + while(idx < props.size() and !found): + found = props[idx].name == backing_name + idx += 1 + + assert_true(found, str(obj, ' has ', backing_name, ' variable.')) + assert_true(obj.has_method(setter_name), str('There should be a setter for ', property_name)) + assert_true(obj.has_method(getter_name), str('There should be a getter for ', property_name)) + + if(pre_fail_count == get_fail_count()): + var call_setter = Callable(obj, setter_name) + var call_getter = Callable(obj, getter_name) + + assert_eq(obj.get(backing_name), default_value, str('Variable ', backing_name, ' has default value.')) + assert_eq(call_getter.call(), default_value, 'Getter returns default value.') + call_setter.call(new_value) + assert_eq(call_getter.call(), new_value, 'Getter returns value from Setter.') + assert_eq(obj.get(backing_name), new_value, str('Variable ', backing_name, ' was set')) + + _warn_for_public_accessors(obj, property_name) + + +## This will verify that the method has a setter and getter for the property. +## It will then use the getter to check the default. Then use the +## setter with new_value and verify the getter returns the same value. +func assert_property(obj, property_name, default_value, new_value) -> void: + var pre_fail_count = get_fail_count() + + var setter_name = str('@', property_name, '_setter') + var getter_name = str('@', property_name, '_getter') + + if(typeof(obj) != TYPE_OBJECT): + _fail(str(_str(obj), ' is not an object')) + return + + assert_has_method(obj, setter_name) + assert_has_method(obj, getter_name) + + if(pre_fail_count == get_fail_count()): + var call_setter = Callable(obj, setter_name) + var call_getter = Callable(obj, getter_name) + + assert_eq(call_getter.call(), default_value, 'Default value') + call_setter.call(new_value) + assert_eq(call_getter.call(), new_value, 'Getter gets Setter value') + + _warn_for_public_accessors(obj, property_name) + + +## Performs a deep comparison between two arrays or dictionaries and asserts +## they are equal. If they are not equal then a formatted list of differences +## are displayed. See [wiki]Comparing-Things[/wiki] for more information. +func assert_eq_deep(v1, v2): + var result = compare_deep(v1, v2) + if(result.are_equal): + _pass(result.get_short_summary()) + else: + _fail(result.summary) + + +## Performs a deep comparison of two arrays or dictionaries and asserts they +## are not equal. See [wiki]Comparing-Things[/wiki] for more information. +func assert_ne_deep(v1, v2): + var result = compare_deep(v1, v2) + if(!result.are_equal): + _pass(result.get_short_summary()) + else: + _fail(result.get_short_summary()) + + +## Assert v1 and v2 are the same using [code]is_same[/code]. See @GlobalScope.is_same. +func assert_same(v1, v2, text=''): + var disp = "[" + _str(v1) + "] expected to be same as [" + _str(v2) + "]: " + text + if(is_same(v1, v2)): + _pass(disp) + else: + _fail(disp) + + +## Assert using v1 and v2 are not the same using [code]is_same[/code]. See @GlobalScope.is_same. +func assert_not_same(v1, v2, text=''): + var disp = "[" + _str(v1) + "] expected to not be same as [" + _str(v2) + "]: " + text + if(is_same(v1, v2)): + _fail(disp) + else: + _pass(disp) + +# ---------------- +#endregion +#region Error Detection +# ---------------- +var _error_type_check_methods = { + "push_error": "is_push_error", + "engine": "is_engine_error", +} + +# smells like GutTrackedError needs some more constants but I'm not ready to +# make them yet +func _is_error_of_type(err, error_type_name): + return err.call(_error_type_check_methods[error_type_name]) + + +func _assert_error_count(count, error_type_name, msg): + var consumed_count = 0 + var errors = gut.error_tracker.get_errors_for_test() + var found = [] + var disp = msg + + for err in errors: + if(_is_error_of_type(err, error_type_name)): + if(consumed_count < count): + err.handled = true + consumed_count += 1 + found.append(err) + + if(disp != ''): + disp = str(': ', disp) + else: + disp = '.' + disp = str("Expected ", count, " ", error_type_name, " errors. Got ", found.size(), disp) + if(found.size() == count): + _pass(disp) + if(!_lgr.is_type_enabled(_lgr.types.passed)): + _lgr.expected_error(msg) + else: + _fail(disp) + + +func _assert_error_text(text, error_type_name, msg): + var consumed_count = 0 + var errors = gut.error_tracker.get_errors_for_test() + var found = [] + var disp = msg + + for err in errors: + if(_is_error_of_type(err, error_type_name) and err.contains_text(text)): + if(consumed_count == 0): + err.handled = true + consumed_count += 1 + found.append(err) + + disp = str("Expected ", error_type_name, " error containing '", text, "'. ", msg) + if(consumed_count == 1): + _pass(disp) + if(!_lgr.is_type_enabled(_lgr.types.passed)): + _lgr.expected_error(disp) + else: + _fail(disp) + + +## Get all the errors in the test up to this point. Each error is an instance +## of [GutTrackedError]. Setting the [member GutTrackedError.handled] [code]handled[/code] property of +## an element in the array will prevent it from causing a test to fail. +## [br][br] +## This method allows you to inspect the details of any errors that occured and +## decide if it's the error you are expecting or not. +## [br][br] +## [codeblock] +## func divide_them(a, b): +## return a / b +## +## func test_with_script_error(): +## divide_them('one', 44) +## push_error('this is a push error') +## var errs = get_errors() +## assert_eq(errs.size(), 2, 'expected error count') +## +## # Maybe inspect some properties of the errors here. +## +## # Mark all the errors as handled. +## for e in errs: +## e.handled = true +## [/codeblock] +## See [GutTrackedError], [wiki]Error-Tracking[/wiki]. +func get_errors()->Array: + return gut.error_tracker.get_errors_for_test() + + +## Asserts that a number of engine or a single engine error continating +## (case insensitive) text has occurred. If the expected error(s) are +## found then this assert will pass and the test will not fail from an +## unexpected push_error. +## [br][br] +## This assert will pass/fail even if push_errors are not configured to cause +## a test failure. This will not prevent the error from showing up in output. +## [br][br] +## [codeblock] +## func divide_them(a, b): +## return a / b +## +## func test_asserting_engine_error_count(): +## divide_them('one', 44) +## assert_engine_error(1, "expecing a script error") +## +## func test_asserting_engine_error_text(): +## divide_them('word', 91) +## assert_engine_error('invalid operands') +## +## func test_asserting_multipe_engine_error_texts(): +## divide_them('foo', Node) +## divide_them(1729, 0) +## assert_engine_error('Division by zero') +## assert_engine_error('invalid operands') +## [/codeblock] +## See [wiki]Error-Tracking[/wiki]. +func assert_engine_error(count_or_text, msg=''): + var t = typeof(count_or_text) + if(t == TYPE_INT or t == TYPE_FLOAT): + _assert_error_count(count_or_text, "engine", msg) + elif(t == TYPE_STRING): + _assert_error_text(count_or_text, 'engine', msg) + else: + _fail(str("Unexpected input: ", count_or_text)) + + +## Asserts that a number of push_errors or a single push error continating +## (case insensitive) text has occurred. If the expected error(s) are +## found then this assert will pass and the test will not fail from an +## unexpected push_error. +## [br][br] +## This assert will pass/fail even if push_errors are not configured to cause +## a test failure. This will not prevent the error from showing up in output. +## [codeblock] +## func test_with_push_error(): +## push_error("This is an error") +## assert_push_error(1, 'This test should have caused a push_error) +## +## func test_push_error_text(): +## push_error("SpecialText") +## assert_push_error("CIALtex") +## +## func test_push_error_multiple_texts(): +## push_error("Error One") +## push_error("Expception two") +## assert_push_error("one") +## assert_push_error("two") +## +## [/codeblock] +## See [wiki]Error-Tracking[/wiki]. +func assert_push_error(count_or_text, msg=''): + var t = typeof(count_or_text) + if(t == TYPE_INT or t == TYPE_FLOAT): + _assert_error_count(count_or_text, "push_error", msg) + elif(t == TYPE_STRING): + _assert_error_text(count_or_text, 'push_error', msg) + else: + _fail(str("Unexpected input: ", count_or_text)) + + +# ---------------- +#endregion +#region Await Helpers +# ---------------- + + +## Use with await to wait an amount of time in seconds. The optional message +## will be printed when the await starts.[br] +## See [wiki]Awaiting[/wiki] +func wait_seconds(time, msg=''): + _awaiter.wait_seconds(time) + return _awaiter.timeout + + +## Use with await to wait for a signal to be emitted or a maximum amount of +## time. Returns true if the signal was emitted, false if not.[br] +## See [wiki]Awaiting[/wiki] +func wait_for_signal(sig : Signal, max_time, msg=''): + watch_signals(sig.get_object()) + _awaiter.wait_for_signal(sig, max_time, msg) + await _awaiter.timeout + return !_awaiter.did_last_wait_timeout + + +## @deprecated +## Use wait_physics_frames or wait_process_frames +## See [wiki]Awaiting[/wiki]. +func wait_frames(frames : int, msg=''): + _lgr.deprecated("wait_frames has been replaced with wait_physics_frames which is counted in _physics_process. " + + "wait_process_frames has also been added which is counted in _process.") + return wait_physics_frames(frames, msg) + + +## This returns a signal that is emitted after [param x] physics frames have +## elpased. You can await this method directly to pause execution for [param x] +## physics frames. The frames are counted prior to _physics_process being called +## on any node (when [signal SceneTree.physics_frame] is emitted). This means the +## signal is emitted after [param x] frames and just before the x + 1 frame starts. +## [codeblock] +## await wait_physics_frames(10) +## [/codeblock] +## See [wiki]Awaiting[/wiki] +func wait_physics_frames(x :int , msg=''): + if(x <= 0): + var text = str('wait_physics_frames: frames must be > 0, you passed ', x, '. 1 frames waited.') + _lgr.error(text) + x = 1 + + _awaiter.wait_physics_frames(x, msg) + return _awaiter.timeout + + +## Alias for [method GutTest.wait_process_frames] +func wait_idle_frames(x : int, msg=''): + return wait_process_frames(x, msg) + + +## This returns a signal that is emitted after [param x] process/idle frames have +## elpased. You can await this method directly to pause execution for [param x] +## process/idle frames. The frames are counted prior to _process being called +## on any node (when [signal SceneTree.process_frame] is emitted). This means the +## signal is emitted after [param x] frames and just before the x + 1 frame starts. +## [codeblock] +## await wait_process_frames(10) +## # wait_idle_frames is an alias of wait_process_frames +## await wait_idle_frames(10) +## [/codeblock] +## See [wiki]Awaiting[/wiki] +func wait_process_frames(x : int, msg=''): + if(x <= 0): + var text = str('wait_process_frames: frames must be > 0, you passed ', x, '. 1 frames waited.') + _lgr.error(text) + x = 1 + + _awaiter.wait_process_frames(x, msg) + return _awaiter.timeout + + +## Use with await to wait for [param callable] to return the boolean value +## [code]true[/code] or a maximum amount of time. All values that are not the +## boolean value [code]true[/code] are ignored. [param callable] is called +## every [code]_physics_process[/code] tick unless an optional time between +## calls is specified.[br] +## [param p3] can be the optional message or an amount of time to wait between calls.[br] +## [param p4] is the optional message if you have specified an amount of time to +## wait between calls.[br] +## Returns [code]true[/code] if [param callable] returned true before the timeout, false if not. +##[br] +##[codeblock] +## var foo = 1 +## func test_example(): +## var foo_func = func(): +## foo += 1 +## return foo == 10 +## foo = 1 +## wait_until(foo_func, 5, 'optional message') +## # or give it a time between +## foo = 1 +## wait_until(foo_func, 5, 1, +## 'this will timeout because we call it every second and are waiting a max of 10 seconds') +## +##[/codeblock] +## See also [method wait_while][br] +## See [wiki]Awaiting[/wiki] +func wait_until(callable, max_time, p3='', p4=''): + var time_between = 0.0 + var message = p4 + if(typeof(p3) != TYPE_STRING): + time_between = p3 + else: + message = p3 + + _awaiter.wait_until(callable, max_time, time_between, message) + await _awaiter.timeout + return !_awaiter.did_last_wait_timeout + + +## This is the inverse of [method wait_until]. This will continue to wait while +## [param callable] returns the boolean value [code]true[/code]. If [b]ANY[/b] +## other value is is returned then the wait will end. +## Returns [code]true[/code] if [param callable] returned a value other than +## [code]true[/code] before the timeout, [code]false[/code] if not. +##[codeblock] +## var foo = 1 +## func test_example(): +## var foo_func = func(): +## foo += 1 +## if(foo < 10): +## return true +## else: +## return 'this is not a boolean' +## foo = 1 +## wait_while(foo_func, 5, 'optional message') +## # or give it a time between +## foo = 1 +## wait_while(foo_func, 5, 1, +## 'this will timeout because we call it every second and are waiting a max of 10 seconds') +## +##[/codeblock] +## See [wiki]Awaiting[/wiki] +func wait_while(callable, max_time, p3='', p4=''): + var time_between = 0.0 + var message = p4 + if(typeof(p3) != TYPE_STRING): + time_between = p3 + else: + message = p3 + + _awaiter.wait_while(callable, max_time, time_between, message) + await _awaiter.timeout + return !_awaiter.did_last_wait_timeout + + + +## Returns whether the last wait_* method timed out. This is always true if +## the last method was wait_xxx_frames or wait_seconds. It will be false when +## using wait_for_signal and wait_until if the timeout occurs before what +## is being waited on. The wait_* methods return this value so you should be +## able to avoid calling this directly, but you can. +func did_wait_timeout(): + return _awaiter.did_last_wait_timeout + +# ---------------- +#endregion +#region Summary Data +# ---------------- + +## @internal +func get_summary(): + return _summary + + +## Returns the number of failing asserts in this script at the time this +## method was called. Call in [method after_all] to get total count for script. +func get_fail_count(): + return _summary.failed + + +## Returns the number of passing asserts in this script at the time this method +## was called. Call in [method after_all] to get total count for script. +func get_pass_count(): + return _summary.passed + + +## Returns the number of pending tests in this script at the time this method +## was called. Call in [method after_all] to get total count for script. +func get_pending_count(): + return _summary.pending + + +## Returns the total number of asserts this script has made as of the time of +## this was called. Call in [method after_all] to get total count for script. +func get_assert_count(): + return _summary.asserts + + +# Convert the _summary dictionary into text +## @internal +func get_summary_text(): + var to_return = get_script().get_path() + "\n" + to_return += str(' ', _summary.passed, ' of ', _summary.asserts, ' passed.') + if(_summary.pending > 0): + to_return += str("\n ", _summary.pending, ' pending') + if(_summary.failed > 0): + to_return += str("\n ", _summary.failed, ' failed.') + return to_return + + +# ---------------- +#endregion +#region Double Methods +# ---------------- + + +## Create a Double of [param thing]. [param thing] should be a Class, script, +## or scene. See [wiki]Doubles[/wiki] +func double(thing, double_strat=null, not_used_anymore=null): + if(!_are_double_parameters_valid(thing, double_strat, not_used_anymore)): + return null + + return _smart_double(thing, double_strat, false) + + +## Create a Partial Double of [param thing]. [param thing] should be a Class, +## script, or scene. See [wiki]Partial-Doubles[/wiki] +func partial_double(thing, double_strat=null, not_used_anymore=null): + if(!_are_double_parameters_valid(thing, double_strat, not_used_anymore)): + return null + + return _smart_double(thing, double_strat, true) + + +## @internal +func double_singleton(singleton_name): + return null + # var to_return = null + # if(_validate_singleton_name(singleton_name)): + # to_return = gut.get_doubler().double_singleton(singleton_name) + # return to_return + + +## @internal +func partial_double_singleton(singleton_name): + return null + # var to_return = null + # if(_validate_singleton_name(singleton_name)): + # to_return = gut.get_doubler().partial_double_singleton(singleton_name) + # return to_return + + +## This was implemented to allow the doubling of classes with static methods. +## There might be other valid use cases for this method, but you should always +## try stubbing before using this method. Using +## [code]stub(my_double, 'method').to_call_super()[/code] or creating a +## [method partial_double] works for any other known scenario. You cannot stub +## or spy on methods passed to [code skip-lint]ignore_method_when_doubling[/code]. +func ignore_method_when_doubling(thing, method_name): + if(typeof(thing) == TYPE_STRING): + _lgr.error('ignore_method_when_doubling no longer supports paths to scripts or scenes. Load them and pass them instead.') + return + + var r = thing + if(thing is PackedScene): + r = GutUtils.get_scene_script_object(thing) + + gut.get_doubler().add_ignored_method(r, method_name) + + +## Stub something. See [wiki]Stubbing[/wiki] for detailed information about stubbing. +func stub(thing, p2=null, p3=null): + var method_name = p2 + var subpath = null + + if(p3 != null): + subpath = p2 + method_name = p3 + + if(GutUtils.is_instance(thing) and !GutUtils.is_double(thing)): + _lgr.error(str("An instance of a Double was expected, you passed: ", _str(thing))) + return GutUtils.StubParams.new() + + var sp = null + if(typeof(thing) == TYPE_CALLABLE): + if(p2 != null or p3 != null): + _lgr.error("Only one parameter expected when using a callable.") + sp = GutUtils.StubParams.new(thing) + else: + sp = GutUtils.StubParams.new(thing, method_name, subpath) + + if(GutUtils.is_instance(sp.stub_target)): + var msg = _get_bad_method_message(sp.stub_target, sp.stub_method, 'stub') + if(msg != ''): + _lgr.error(msg) + return GutUtils.StubParams.new() + + sp.logger = _lgr + gut.get_stubber().add_stub(sp) + return sp + + +# ---------------- +#endregion +#region Memory Mgmt +# ---------------- + + +## Marks whatever is passed in to be freed after the test finishes. It also +## returns what is passed in so you can save a line of code. +## var thing = autofree(Thing.new()) +func autofree(thing): + gut.get_autofree().add_free(thing) + return thing + + +## Works the same as autofree except queue_free will be called on the object +## instead. This also imparts a brief pause after the test finishes so that +## the queued object has time to free. +func autoqfree(thing): + gut.get_autofree().add_queue_free(thing) + return thing + + +## The same as autofree but it also adds the object as a child of the test. +func add_child_autofree(node, legible_unique_name = false): + gut.get_autofree().add_free(node) + # Explicitly calling super here b/c add_child MIGHT change and I don't want + # a bug sneaking its way in here. + super.add_child(node, legible_unique_name) + return node + + +## The same as autoqfree but it also adds the object as a child of the test. +func add_child_autoqfree(node, legible_unique_name=false): + gut.get_autofree().add_queue_free(node) + # Explicitly calling super here b/c add_child MIGHT change and I don't want + # a bug sneaking its way in here. + super.add_child(node, legible_unique_name) + return node + + +# ---------------- +#endregion +#region Deprecated/Removed +# ---------------- + + +## REMOVED +## @ignore +func compare_shallow(v1, v2, max_differences=null): + _fail('compare_shallow has been removed. Use compare_deep or just compare using == instead.') + _lgr.error('compare_shallow has been removed. Use compare_deep or just compare using == instead.') + return null + + +## REMOVED +## @ignore +func assert_eq_shallow(v1, v2): + _fail('assert_eq_shallow has been removed. Use assert_eq/assert_same/assert_eq_deep') + + +## REMOVED +## @ignore +func assert_ne_shallow(v1, v2): + _fail('assert_eq_shallow has been removed. Use assert_eq/assert_same/assert_eq_deep') + + +## @deprecated: use wait_seconds +func yield_for(time, msg=''): + _lgr.deprecated('yield_for', 'wait_seconds') + return wait_seconds(time, msg) + + +## @deprecated: use wait_for_signal +func yield_to(obj, signal_name, max_wait, msg=''): + _lgr.deprecated('yield_to', 'wait_for_signal') + return await wait_for_signal(Signal(obj, signal_name), max_wait, msg) + + +## @deprecated: use wait_frames +func yield_frames(frames, msg=''): + _lgr.deprecated("yield_frames", "wait_frames") + return wait_frames(frames, msg) + + +## @deprecated: no longer supported. Use double +func double_scene(path, strategy=null): + _lgr.deprecated('test.double_scene has been removed.', 'double') + return null + + +## @deprecated: no longer supported. Use double +func double_script(path, strategy=null): + _lgr.deprecated('test.double_script has been removed.', 'double') + return null + + # var override_strat = GutUtils.nvl(strategy, gut.get_doubler().get_strategy()) + # return gut.get_doubler().double(path, override_strat) + + +## @deprecated: no longer supported. Use register_inner_classes + double +func double_inner(path, subpath, strategy=null): + _lgr.deprecated('double_inner should not be used. Use register_inner_classes and double instead.', 'double') + return null + + var override_strat = GutUtils.nvl(strategy, gut.get_doubler().get_strategy()) + return gut.get_doubler().double_inner(path, subpath, override_strat) + + +## @deprecated: Use [method assert_called_count] instead. +func assert_call_count(inst, method_name, expected_count, parameters=null): + gut.logger.deprecated('This has been replaced with assert_called_count which accepts a Callable with optional bound arguments.') + var callable = Callable.create(inst, method_name) + if(parameters != null): + callable = callable.bindv(parameters) + assert_called_count(callable, expected_count) + + +## @deprecated: no longer supported. +func assert_setget( + instance, name_property, + const_or_setter = null, getter="__not_set__"): + _lgr.deprecated('assert_property') + _fail('assert_setget has been removed. Use assert_property, assert_set_property, assert_readonly_property instead.') + + +# ---------------- +#endregion + + + +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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. +# +# ############################################################################## +# View readme for usage details. +# +# Version - see gut.gd +# ############################################################################## +# Class that all test scripts must extend.` +# +# This provides all the asserts and other testing features. Test scripts are +# run by the Gut class in gut.gd +# ############################################################################## diff --git a/addons/gut/test.gd.uid b/addons/gut/test.gd.uid new file mode 100644 index 0000000..222586f --- /dev/null +++ b/addons/gut/test.gd.uid @@ -0,0 +1 @@ +uid://cnup2nbj45r4s diff --git a/addons/gut/test_collector.gd b/addons/gut/test_collector.gd new file mode 100644 index 0000000..a5a6682 --- /dev/null +++ b/addons/gut/test_collector.gd @@ -0,0 +1,276 @@ +# ------------------------------------------------------------------------------ +# This class handles calling out to the test parser and maintaining an array of +# collected_script.gd. This is used for both calling the tests and tracking +# the results of each script and test's execution. +# +# This also handles exporting and importing tests. +# ------------------------------------------------------------------------------ +var CollectedScript = GutUtils.CollectedScript +var CollectedTest = GutUtils.CollectedTest + +var _test_prefix = 'test_' +var _test_class_prefix = 'Test' + +var _lgr = GutUtils.get_logger() + + +# Array of CollectedScripts. +var scripts = [] + + +func _does_inherit_from_test(thing): + var base_script = thing.get_base_script() + var to_return = false + if(base_script != null): + var base_path = base_script.get_path() + if(base_path == 'res://addons/gut/test.gd'): + to_return = true + else: + to_return = _does_inherit_from_test(base_script) + return to_return + + +func _populate_tests(test_script): + var script = test_script.load_script() + if(script == null): + print(' !!! ', test_script.path, ' could not be loaded') + return false + + test_script.is_loaded = true + var methods = script.get_script_method_list() + for i in range(methods.size()): + var name = methods[i]['name'] + if(name.begins_with(_test_prefix)): + var t = CollectedTest.new() + t.name = name + t.arg_count = methods[i]['args'].size() + test_script.tests.append(t) + t.collected_script = weakref(test_script) + + +func _get_inner_test_class_names(loaded): + var inner_classes = [] + var const_map = loaded.get_script_constant_map() + for key in const_map: + var thing = const_map[key] + if(GutUtils.is_gdscript(thing)): + if(key.begins_with(_test_class_prefix)): + if(_does_inherit_from_test(thing)): + inner_classes.append(key) + else: + _lgr.warn(str('Ignoring Inner Class ', key, + ' because it does not extend GutTest')) + + # This could go deeper and find inner classes within inner classes + # but requires more experimentation. Right now I'm keeping it at + # one level since that is what the previous version did and there + # has been no demand for deeper nesting. + # _populate_inner_test_classes(thing) + return inner_classes + + +func _parse_script(test_script): + var inner_classes = [] + var scripts_found = [] + + var loaded = GutUtils.WarningsManager.load_script_using_custom_warnings( + test_script.path, + GutUtils.warnings_when_loading_test_scripts) + + if(_does_inherit_from_test(loaded)): + _populate_tests(test_script) + scripts_found.append(test_script.path) + inner_classes = _get_inner_test_class_names(loaded) + else: + return [] + + for i in range(inner_classes.size()): + var loaded_inner = loaded.get(inner_classes[i]) + if(_does_inherit_from_test(loaded_inner)): + var ts = CollectedScript.new(_lgr) + ts.path = test_script.path + ts.inner_class_name = inner_classes[i] + _populate_tests(ts) + scripts.append(ts) + scripts_found.append(test_script.path + '[' + inner_classes[i] +']') + + return scripts_found + + +# ----------------- +# Public +# ----------------- +func add_script(path): + # SHORTCIRCUIT + if(has_script(path)): + return [] + + # SHORTCIRCUIT + if(!FileAccess.file_exists(path)): + # This check was added so tests could create dynmaic scripts and add + # them to be run through gut. This helps cut down on creating test + # scripts to be used in test/resources. + if(ResourceLoader.has_cached(path)): + _lgr.debug("Using cached version of " + path) + else: + _lgr.error('Could not find script: ' + path) + return + + var ts = CollectedScript.new(_lgr) + ts.path = path + # Append right away because if we don't test_doubler.gd.TestInitParameters + # will HARD crash. I couldn't figure out what was causing the issue but + # appending right away, and then removing if it's not valid seems to fix + # things. It might have to do with the ordering of the test classes in + # the test collecter. I'm not really sure. + scripts.append(ts) + var parse_results = _parse_script(ts) + + if(parse_results.find(path) == -1): + _lgr.warn(str('Ignoring script ', path, ' because it does not extend GutTest')) + scripts.remove_at(scripts.find(ts)) + + return parse_results + + +func clear(): + scripts.clear() + + +func has_script(path): + var found = false + var idx = 0 + while(idx < scripts.size() and !found): + if(scripts[idx].get_full_name() == path): + found = true + else: + idx += 1 + return found + + +func export_tests(path): + var success = true + var f = ConfigFile.new() + for i in range(scripts.size()): + scripts[i].export_to(f, str('CollectedScript-', i)) + var result = f.save(path) + if(result != OK): + _lgr.error(str('Could not save exported tests to [', path, ']. Error code: ', result)) + success = false + return success + + +func import_tests(path): + var success = false + var f = ConfigFile.new() + var result = f.load(path) + if(result != OK): + _lgr.error(str('Could not load exported tests from [', path, ']. Error code: ', result)) + else: + var sections = f.get_sections() + for key in sections: + var ts = CollectedScript.new(_lgr) + ts.import_from(f, key) + _populate_tests(ts) + scripts.append(ts) + success = true + return success + + +func get_script_named(name): + return GutUtils.search_array(scripts, 'get_filename_and_inner', name) + + +func get_test_named(script_name, test_name): + var s = get_script_named(script_name) + if(s != null): + return s.get_test_named(test_name) + else: + return null + + +func to_s(): + var to_return = '' + for i in range(scripts.size()): + to_return += scripts[i].to_s() + "\n" + return to_return + +# --------------------- +# Accessors +# --------------------- +func get_logger(): + return _lgr + + +func set_logger(logger): + _lgr = logger + + +func get_test_prefix(): + return _test_prefix + + +func set_test_prefix(test_prefix): + _test_prefix = test_prefix + + +func get_test_class_prefix(): + return _test_class_prefix + + +func set_test_class_prefix(test_class_prefix): + _test_class_prefix = test_class_prefix + + +func get_scripts(): + return scripts + + +func get_ran_test_count(): + var count = 0 + for s in scripts: + count += s.get_ran_test_count() + return count + + +func get_ran_script_count(): + var count = 0 + for s in scripts: + if(s.was_run): + count += 1 + return count + +func get_test_count(): + var count = 0 + for s in scripts: + count += s.tests.size() + return count + + +func get_assert_count(): + var count = 0 + for s in scripts: + count += s.get_assert_count() + return count + + +func get_pass_count(): + var count = 0 + for s in scripts: + count += s.get_pass_count() + return count + + +func get_fail_count(): + var count = 0 + for s in scripts: + count += s.get_fail_count() + return count + + +func get_pending_count(): + var count = 0 + for s in scripts: + count += s.get_pending_count() + return count + diff --git a/addons/gut/test_collector.gd.uid b/addons/gut/test_collector.gd.uid new file mode 100644 index 0000000..8f83309 --- /dev/null +++ b/addons/gut/test_collector.gd.uid @@ -0,0 +1 @@ +uid://cly8ws3u71jk5 diff --git a/addons/gut/thing_counter.gd b/addons/gut/thing_counter.gd new file mode 100644 index 0000000..40e71ef --- /dev/null +++ b/addons/gut/thing_counter.gd @@ -0,0 +1,55 @@ +var things = {} + +func get_unique_count(): + return things.size() + + +func add_thing_to_count(thing): + if(!things.has(thing)): + things[thing] = 0 + + +func add(thing): + if(things.has(thing)): + things[thing] += 1 + else: + things[thing] = 1 + + +func has(thing): + return things.has(thing) + + +func count(thing): + var to_return = 0 + if(things.has(thing)): + to_return = things[thing] + return to_return + + +func sum(): + var to_return = 0 + for key in things: + to_return += things[key] + return to_return + + +func to_s(): + var to_return = "" + for key in things: + to_return += str(key, ": ", things[key], "\n") + to_return += str("sum: ", sum()) + return to_return + + +func get_max_count(): + var max_val = null + for key in things: + if(max_val == null or things[key] > max_val): + max_val = things[key] + return max_val + + +func add_array_items(array): + for i in range(array.size()): + add(array[i]) diff --git a/addons/gut/thing_counter.gd.uid b/addons/gut/thing_counter.gd.uid new file mode 100644 index 0000000..537b433 --- /dev/null +++ b/addons/gut/thing_counter.gd.uid @@ -0,0 +1 @@ +uid://8evk5cwvo2nu diff --git a/addons/gut/utils.gd b/addons/gut/utils.gd new file mode 100644 index 0000000..cd43e04 --- /dev/null +++ b/addons/gut/utils.gd @@ -0,0 +1,654 @@ +@tool +class_name GutUtils +extends Object + +const GUT_METADATA = '__gutdbl' + +# Note, these cannot change since places are checking for TYPE_INT to determine +# how to process parameters. +enum DOUBLE_STRATEGY{ + INCLUDE_NATIVE, + SCRIPT_ONLY, +} + +enum DIFF { + DEEP, + SIMPLE +} + +const TEST_STATUSES = { + NO_ASSERTS = 'no asserts', + SKIPPED = 'skipped', + NOT_RUN = 'not run', + PENDING = 'pending', + # These two got the "ed" b/c pass is a reserved word and I could not + # think of better words. + FAILED = 'fail', + PASSED = 'pass' +} + +const DOUBLE_TEMPLATES = { + FUNCTION = 'res://addons/gut/double_templates/function_template.txt', + INIT = 'res://addons/gut/double_templates/init_template.txt', + SCRIPT = 'res://addons/gut/double_templates/script_template.txt', +} + +const NOTHING := '__NOTHING__' +const NO_TEST := 'NONE' +const GUT_ERROR_TYPE = 999 + +enum TREAT_AS { + NOTHING, + FAILURE, +} + + +## This dictionary defaults to all the native classes that we cannot call new +## on. It is further populated during a run so that we only have to create +## a new instance once to get the class name string. +static var gdscript_native_class_names_by_type = { + Tween:"Tween" +} + + +static var GutScene = load('res://addons/gut/GutScene.tscn') +static var LazyLoader = load('res://addons/gut/lazy_loader.gd') +static var VersionNumbers = load("res://addons/gut/version_numbers.gd") +static var WarningsManager = load("res://addons/gut/warnings_manager.gd") +static var EditorGlobals = load("res://addons/gut/gui/editor_globals.gd") +static var RunExternallyScene = load("res://addons/gut/gui/RunExternally.tscn") + +# -------------------------------- +# Lazy loaded scripts. These scripts are lazy loaded so that they can be +# declared, but will not load when this script is loaded. This gives us a +# window at the start of a run to adjust warning levels prior to loading +# everything. +# -------------------------------- +static var AutoFree = LazyLoader.new('res://addons/gut/autofree.gd'): + get: return AutoFree.get_loaded() + set(val): pass +static var Awaiter = LazyLoader.new('res://addons/gut/awaiter.gd'): + get: return Awaiter.get_loaded() + set(val): pass +static var Comparator = LazyLoader.new('res://addons/gut/comparator.gd'): + get: return Comparator.get_loaded() + set(val): pass +static var CollectedTest = LazyLoader.new('res://addons/gut/collected_test.gd'): + get: return CollectedTest.get_loaded() + set(val): pass +static var CollectedScript = LazyLoader.new('res://addons/gut/collected_script.gd'): + get: return CollectedScript.get_loaded() + set(val): pass +static var CompareResult = LazyLoader.new('res://addons/gut/compare_result.gd'): + get: return CompareResult.get_loaded() + set(val): pass +static var DiffFormatter = LazyLoader.new("res://addons/gut/diff_formatter.gd"): + get: return DiffFormatter.get_loaded() + set(val): pass +static var DiffTool = LazyLoader.new('res://addons/gut/diff_tool.gd'): + get: return DiffTool.get_loaded() + set(val): pass +static var DoubleTools = LazyLoader.new("res://addons/gut/double_tools.gd"): + get: return DoubleTools.get_loader() + set(val): pass +static var Doubler = LazyLoader.new('res://addons/gut/doubler.gd'): + get: return Doubler.get_loaded() + set(val): pass +static var DynamicGdScript = LazyLoader.new("res://addons/gut/dynamic_gdscript.gd") : + get: return DynamicGdScript.get_loaded() + set(val): pass +static var Gut = LazyLoader.new('res://addons/gut/gut.gd'): + get: return Gut.get_loaded() + set(val): pass +static var GutConfig = LazyLoader.new('res://addons/gut/gut_config.gd'): + get: return GutConfig.get_loaded() + set(val): pass +static var GutFonts = LazyLoader.new("res://addons/gut/gut_fonts.gd"): + get: return GutFonts.get_loaded() + set(val): pass +static var HookScript = LazyLoader.new('res://addons/gut/hook_script.gd'): + get: return HookScript.get_loaded() + set(val): pass +static var InnerClassRegistry = LazyLoader.new('res://addons/gut/inner_class_registry.gd'): + get: return InnerClassRegistry.get_loaded() + set(val): pass +static var InputFactory = LazyLoader.new("res://addons/gut/input_factory.gd"): + get: return InputFactory.get_loaded() + set(val): pass +static var InputSender = LazyLoader.new("res://addons/gut/input_sender.gd"): + get: return InputSender.get_loaded() + set(val): pass +static var JunitXmlExport = LazyLoader.new('res://addons/gut/junit_xml_export.gd'): + get: return JunitXmlExport.get_loaded() + set(val): pass +static var GutLogger = LazyLoader.new('res://addons/gut/logger.gd') : # everything should use get_logger + get: return GutLogger.get_loaded() + set(val): pass +static var MethodMaker = LazyLoader.new('res://addons/gut/method_maker.gd'): + get: return MethodMaker.get_loaded() + set(val): pass +static var OneToMany = LazyLoader.new('res://addons/gut/one_to_many.gd'): + get: return OneToMany.get_loaded() + set(val): pass +static var OptionMaker = LazyLoader.new('res://addons/gut/gui/option_maker.gd'): + get: return OptionMaker.get_loaded() + set(val): pass +static var OrphanCounter = LazyLoader.new('res://addons/gut/orphan_counter.gd'): + get: return OrphanCounter.get_loaded() + set(val): pass +static var ParameterFactory = LazyLoader.new('res://addons/gut/parameter_factory.gd'): + get: return ParameterFactory.get_loaded() + set(val): pass +static var ParameterHandler = LazyLoader.new('res://addons/gut/parameter_handler.gd'): + get: return ParameterHandler.get_loaded() + set(val): pass +static var Printers = LazyLoader.new('res://addons/gut/printers.gd'): + get: return Printers.get_loaded() + set(val): pass +static var ResultExporter = LazyLoader.new('res://addons/gut/result_exporter.gd'): + get: return ResultExporter.get_loaded() + set(val): pass +static var ScriptCollector = LazyLoader.new('res://addons/gut/script_parser.gd'): + get: return ScriptCollector.get_loaded() + set(val): pass +static var SignalWatcher = LazyLoader.new('res://addons/gut/signal_watcher.gd'): + get: return SignalWatcher.get_loaded() + set(val): pass +static var Spy = LazyLoader.new('res://addons/gut/spy.gd'): + get: return Spy.get_loaded() + set(val): pass +static var Strutils = LazyLoader.new('res://addons/gut/strutils.gd'): + get: return Strutils.get_loaded() + set(val): pass +static var Stubber = LazyLoader.new('res://addons/gut/stubber.gd'): + get: return Stubber.get_loaded() + set(val): pass +static var StubParams = LazyLoader.new('res://addons/gut/stub_params.gd'): + get: return StubParams.get_loaded() + set(val): pass +static var Summary = LazyLoader.new('res://addons/gut/summary.gd'): + get: return Summary.get_loaded() + set(val): pass +static var Test = LazyLoader.new('res://addons/gut/test.gd'): + get: return Test.get_loaded() + set(val): pass +static var TestCollector = LazyLoader.new('res://addons/gut/test_collector.gd'): + get: return TestCollector.get_loaded() + set(val): pass +static var ThingCounter = LazyLoader.new('res://addons/gut/thing_counter.gd'): + get: return ThingCounter.get_loaded() + set(val): pass +# -------------------------------- + +static var gut_fonts = GutFonts.new() +static var avail_fonts = gut_fonts.get_font_names() + +static var version_numbers = VersionNumbers.new( + # gut_versrion (source of truth) + '9.5.0', + # required_godot_version + '4.5' +) + + +static var warnings_at_start := { # WarningsManager dictionary + exclude_addons = true +} + +static var warnings_when_loading_test_scripts := { # WarningsManager dictionary + enable = false +} + + +# ------------------------------------------------------------------------------ +# Everything should get a logger through this. +# +# When running in test mode this will always return a new logger so that errors +# are not caused by getting bad warn/error/etc counts. +# ------------------------------------------------------------------------------ +static var _lgr = null +static func get_logger(): + if(_lgr == null): + _lgr = GutLogger.new() + return _lgr + +static var _error_tracker = null +static func get_error_tracker(): + if(_error_tracker == null): + _error_tracker = GutErrorTracker.new() + return _error_tracker + + +static var _dyn_gdscript = DynamicGdScript.new() +static func create_script_from_source(source, override_path=null): + var are_warnings_enabled = WarningsManager.are_warnings_enabled() + WarningsManager.enable_warnings(false) + + var DynamicScript = _dyn_gdscript.create_script_from_source(source, override_path) + if(typeof(DynamicScript) == TYPE_INT): + var l = get_logger() + l.error(str('Could not create script from source. Error: ', DynamicScript)) + l.info(str("Source Code:\n", add_line_numbers(source))) + + WarningsManager.enable_warnings(are_warnings_enabled) + + return DynamicScript + + +# Get the EditorInterface instance without having to make a direct reference to +# it. This allows for testing to be done on editor scripts that require it +# without having the parser error when you refer to it when not in the editor. +static func get_editor_interface(): + if(Engine.is_editor_hint()): + var inst = load("res://addons/gut/get_editor_interface.gd").new() + return inst.get_it() + else: + return null + + + +static func godot_version_string(): + return version_numbers.make_godot_version_string() + + +static func is_godot_version(expected): + return VersionNumbers.VerNumTools.is_godot_version_eq(expected) + + +static func is_godot_version_gte(expected): + return VersionNumbers.VerNumTools.is_godot_version_gte(expected) + + +const INSTALL_OK_TEXT = 'Everything checks out' +static func make_install_check_text(template_paths=DOUBLE_TEMPLATES, ver_nums=version_numbers): + var text = INSTALL_OK_TEXT + if(!FileAccess.file_exists(template_paths.FUNCTION) or + !FileAccess.file_exists(template_paths.INIT) or + !FileAccess.file_exists(template_paths.SCRIPT)): + + text = 'One or more GUT template files are missing. If this is an exported project, you must include *.txt files in the export to run GUT. If it is not an exported project then reinstall GUT.' + elif(!ver_nums.is_godot_version_valid()): + text = ver_nums.get_bad_version_text() + + return text + + +static func is_install_valid(template_paths=DOUBLE_TEMPLATES, ver_nums=version_numbers): + return make_install_check_text(template_paths, ver_nums) == INSTALL_OK_TEXT + + +# ------------------------------------------------------------------------------ +# Gets the root node without having to be in the tree and pushing out an error +# if we don't have a main loop ready to go yet. +# ------------------------------------------------------------------------------ +# static func get_root_node(): +# var main_loop = Engine.get_main_loop() +# if(main_loop != null): +# return main_loop.root +# else: +# push_error('No Main Loop Yet') +# return null + + +# ------------------------------------------------------------------------------ +# Gets the value from an enum. +# - If passed an integer value as a string it will convert it to an int and +# processes the int value. +# - If the value is a float then it is converted to an int and then processes +# the int value +# - If the value is an int, or was converted to an int, then the enum is checked +# to see if it contains the value, if so then the value is returned. +# Otherwise the default is returned. +# - If the value is a string then it is uppercased and all spaces are replaced +# with underscores. It then checks to see if enum contains a key of that +# name. If so then the value for that key is returned, otherwise the default +# is returned. +# +# This description is longer than the code, you should have just read the code +# and the tests. +# ------------------------------------------------------------------------------ +static func get_enum_value(thing, e, default=null): + var to_return = default + + if(typeof(thing) == TYPE_STRING and str(thing.to_int()) == thing): + thing = thing.to_int() + elif(typeof(thing) == TYPE_FLOAT): + thing = int(thing) + + if(typeof(thing) == TYPE_STRING): + var converted = thing.to_upper().replace(' ', '_') + if(e.keys().has(converted)): + to_return = e[converted] + else: + if(e.values().has(thing)): + to_return = thing + + return to_return + + +# ------------------------------------------------------------------------------ +# return if_null if value is null otherwise return value +# ------------------------------------------------------------------------------ +static func nvl(value, if_null): + if(value == null): + return if_null + else: + return value + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +static func pretty_print(dict, indent = ' '): + print(JSON.stringify(dict, indent)) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +static func print_properties(props, thing, print_all_meta=false): + for i in range(props.size()): + var prop_name = props[i].name + var prop_value = thing.get(props[i].name) + var print_value = str(prop_value) + if(print_value.length() > 100): + print_value = print_value.substr(0, 97) + '...' + elif(print_value == ''): + print_value = 'EMPTY' + + print(prop_name, ' = ', print_value) + if(print_all_meta): + print(' ', props[i]) + + +static func print_method_list(thing): + for entry in thing.get_method_list(): + print("* ", entry.name) + + +# ------------------------------------------------------------------------------ +# Gets the value of the node_property 'script' from a PackedScene's root node. +# This does not assume the location of the root node in the PackedScene's node +# list. This also does not assume the index of the 'script' node property in +# a nodes's property list. +# ------------------------------------------------------------------------------ +static func get_scene_script_object(scene): + var state = scene.get_state() + var to_return = null + var root_node_path = NodePath(".") + var node_idx = 0 + + while(node_idx < state.get_node_count() and to_return == null): + if(state.get_node_path(node_idx) == root_node_path): + for i in range(state.get_node_property_count(node_idx)): + if(state.get_node_property_name(node_idx, i) == 'script'): + to_return = state.get_node_property_value(node_idx, i) + + node_idx += 1 + + return to_return + + +# ------------------------------------------------------------------------------ +# returns true if the object has been freed, false if not +# +# From what i've read, the weakref approach should work. It seems to work most +# of the time but sometimes it does not catch it. The str comparison seems to +# fill in the gaps. I've not seen any errors after adding that check. +# ------------------------------------------------------------------------------ +static func is_freed(obj): + var wr = weakref(obj) + return !(wr.get_ref() and str(obj) != '') + + +# ------------------------------------------------------------------------------ +# Pretty self explanitory. +# ------------------------------------------------------------------------------ +static func is_not_freed(obj): + return !is_freed(obj) + + +# ------------------------------------------------------------------------------ +# Checks if the passed in object is a GUT Double or Partial Double. +# ------------------------------------------------------------------------------ +static func is_double(obj): + var to_return = false + if(typeof(obj) == TYPE_OBJECT and is_instance_valid(obj)): + to_return = obj.has_method('__gutdbl_check_method__') + return to_return + + +# ------------------------------------------------------------------------------ +# Checks an object to see if it is a GDScriptNativeClass +# ------------------------------------------------------------------------------ +static func is_native_class(thing): + var it_is = false + if(typeof(thing) == TYPE_OBJECT): + it_is = str(thing).begins_with("= 0): + temp = decimal_value >> count + if(temp & 1): + binary_string = binary_string + "1" + else: + binary_string = binary_string + "0" + count -= 1 + + return binary_string + + +static func add_line_numbers(contents): + if(contents == null): + return '' + + var to_return = "" + var lines = contents.split("\n") + var line_num = 1 + for line in lines: + var line_str = str(line_num).lpad(6, ' ') + to_return += str(line_str, ' |', line, "\n") + line_num += 1 + return to_return + + +static func get_display_size(): + return Engine.get_main_loop().get_viewport().get_visible_rect() + + + +static func find_method_meta(methods, method_name): + var meta = null + var idx = 0 + while (idx < methods.size() and meta == null): + var m = methods[idx] + if(m.name == method_name): + meta = m + idx += 1 + + return meta + + +static func get_method_meta(object, method_name): + return find_method_meta(object.get_method_list(), method_name) + +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2025 Tom "Butch" Wesley +# +# 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/gut/utils.gd.uid b/addons/gut/utils.gd.uid new file mode 100644 index 0000000..c3d591d --- /dev/null +++ b/addons/gut/utils.gd.uid @@ -0,0 +1 @@ +uid://dbfbnvoq5osf2 diff --git a/addons/gut/version_conversion.gd b/addons/gut/version_conversion.gd new file mode 100644 index 0000000..fe37578 --- /dev/null +++ b/addons/gut/version_conversion.gd @@ -0,0 +1,120 @@ +class ConfigurationUpdater: + var EditorGlobals = load("res://addons/gut/gui/editor_globals.gd") + + func warn(message): + print('GUT Warning: ', message) + + + func info(message): + print("GUT Info: ", message) + + + func moved_file(from, to): + if(FileAccess.file_exists(from) and !FileAccess.file_exists(to)): + info(str('Copying [', from, '] to [', to, ']')) + var result = DirAccess.copy_absolute(from, to) + if(result != OK): + warn(str('Could not copy [', from, '] to [', to, ']')) + + if(FileAccess.file_exists(from) and FileAccess.file_exists(to)): + warn(str('File [', from, '] has been moved to [', to, "].\n You can delete ", from)) + + + func move_user_file(from, to): + if(from.begins_with('user://') and to.begins_with('user://')): + if(FileAccess.file_exists(from) and !FileAccess.file_exists(to)): + info(str('Moving [', from, '] to [', to, ']')) + var result = DirAccess.copy_absolute(from, to) + if(result == OK): + info(str(' ', 'Created ', to)) + result = DirAccess.remove_absolute(from) + if(result != OK): + warn(str(' ', 'Could not delete ', from)) + else: + info(str(' ', 'Deleted ', from)) + else: + warn(str(' ', 'Could not copy [', from, '] to [', to, ']')) + else: + warn(str('Attempt to move_user_file with files not in user:// ', from, '->', to)) + + + func remove_user_file(which): + if(which.begins_with('user://') and FileAccess.file_exists(which)): + info(str('Deleting obsolete file ', which)) + var result = DirAccess.remove_absolute(which) + if(result != OK): + warn(str(' ', 'Could not delete ', which)) + else: + info(str(' ', 'Deleted ', which)) + +class v9_2_0: + extends ConfigurationUpdater + + func validate(): + moved_file('res://.gut_editor_config.json', EditorGlobals.editor_run_gut_config_path) + moved_file('res://.gut_editor_shortcuts.cfg', EditorGlobals.editor_shortcuts_path) + remove_user_file('user://.gut_editor.bbcode') + remove_user_file('user://.gut_editor.json') + +# list=Array[Dictionary]([{ +# "base": &"RefCounted", +# "class": &"DynamicGutTest", +# "icon": "", +# "language": &"GDScript", +# "path": "res://test/resources/tools/dynamic_gut_test.gd" +# }, { +# "base": &"RefCounted", +# "class": &"GutDoubleTestInnerClasses", +# "icon": "", +# "language": &"GDScript", +# "path": "res://test/resources/doubler_test_objects/inner_classes.gd" +# }, ... ]) +static func get_missing_gut_class_names() -> Array: + var gut_class_names = [ + "GutErrorTracker", + "GutHookScript", + "GutInputFactory", + "GutInputSender", + "GutMain", + "GutStringUtils", + "GutTest", + "GutTrackedError", + "GutUtils", + ] + + var class_cach_path = 'res://.godot/global_script_class_cache.cfg' + var cfg = ConfigFile.new() + cfg.load(class_cach_path) + + var all_class_names = {} + var missing = [] + var class_cache_entries = cfg.get_value('', 'list', []) + + for entry in class_cache_entries: + if(entry.path.begins_with(&"res://addons/gut/")): + # print(entry["class"], ': ', entry["path"]) + all_class_names[entry["class"]] = entry + + for cn in gut_class_names: + if(!all_class_names.has(cn)): + missing.append(cn) + + return missing + + +static func error_if_not_all_classes_imported() -> bool: + var missing_class_names = get_missing_gut_class_names() + if(missing_class_names.size() > 0): + push_error(str("Some GUT class_names have not been imported. Please restart the Editor or run godot --headless --import\n", + "Missing class_names: ", + missing_class_names)) + return true + else: + return false + + + + +static func convert(): + var inst = v9_2_0.new() + inst.validate() diff --git a/addons/gut/version_conversion.gd.uid b/addons/gut/version_conversion.gd.uid new file mode 100644 index 0000000..e69ecb7 --- /dev/null +++ b/addons/gut/version_conversion.gd.uid @@ -0,0 +1 @@ +uid://c8twdri50qrkb diff --git a/addons/gut/version_numbers.gd b/addons/gut/version_numbers.gd new file mode 100644 index 0000000..c8c0f32 --- /dev/null +++ b/addons/gut/version_numbers.gd @@ -0,0 +1,122 @@ +# ############################################################################## +# +# ############################################################################## +class VerNumTools: + + static func _make_version_array_from_string(v): + var parts = Array(v.split('.')) + for i in range(parts.size()): + var int_val = parts[i].to_int() + if(str(int_val) == parts[i]): + parts[i] = parts[i].to_int() + return parts + + + static func make_version_array(v): + var to_return = [] + if(typeof(v) == TYPE_STRING): + to_return = _make_version_array_from_string(v) + elif(typeof(v) == TYPE_DICTIONARY): + return [v.major, v.minor, v.patch] + elif(typeof(v) == TYPE_ARRAY): + to_return = v + return to_return + + + static func make_version_string(version_parts): + var to_return = 'x.x.x' + if(typeof(version_parts) == TYPE_ARRAY): + to_return = ".".join(version_parts) + elif(typeof(version_parts) == TYPE_DICTIONARY): + to_return = str(version_parts.major, '.', version_parts.minor, '.', version_parts.patch) + elif(typeof(version_parts) == TYPE_STRING): + to_return = version_parts + return to_return + + + static func is_version_gte(version, required): + var is_ok = null + var v = make_version_array(version) + var r = make_version_array(required) + + var idx = 0 + while(is_ok == null and idx < v.size() and idx < r.size()): + if(v[idx] > r[idx]): + is_ok = true + elif(v[idx] < r[idx]): + is_ok = false + + idx += 1 + + # still null means each index was the same. + return GutUtils.nvl(is_ok, true) + + + static func is_version_eq(version, expected): + var version_array = make_version_array(version) + var expected_array = make_version_array(expected) + + if(expected_array.size() > version_array.size()): + return false + + var is_version = true + var i = 0 + while(i < expected_array.size() and i < version_array.size() and is_version): + if(expected_array[i] == version_array[i]): + i += 1 + else: + is_version = false + + return is_version + + + static func is_godot_version_eq(expected): + return VerNumTools.is_version_eq(Engine.get_version_info(), expected) + + + static func is_godot_version_gte(expected): + return VerNumTools.is_version_gte(Engine.get_version_info(), expected) + + + + +# ############################################################################## +# +# ############################################################################## +var gut_version = '0.0.0' +var required_godot_version = '0.0.0' + +func _init(gut_v = gut_version, required_godot_v = required_godot_version): + gut_version = gut_v + required_godot_version = required_godot_v + + +# ------------------------------------------------------------------------------ +# Blurb of text with GUT and Godot versions. +# ------------------------------------------------------------------------------ +func get_version_text(): + var v_info = Engine.get_version_info() + var gut_version_info = str('GUT version: ', gut_version) + var godot_version_info = str('Godot version: ', v_info.major, '.', v_info.minor, '.', v_info.patch) + return godot_version_info + "\n" + gut_version_info + + +# ------------------------------------------------------------------------------ +# Returns a nice string for erroring out when we have a bad Godot version. +# ------------------------------------------------------------------------------ +func get_bad_version_text(): + var info = Engine.get_version_info() + var gd_version = str(info.major, '.', info.minor, '.', info.patch) + return 'GUT ' + gut_version + ' requires Godot ' + required_godot_version + \ + ' or greater. Godot version is ' + gd_version + + +# ------------------------------------------------------------------------------ +# Checks the Godot version against required_godot_version. +# ------------------------------------------------------------------------------ +func is_godot_version_valid(): + return VerNumTools.is_version_gte(Engine.get_version_info(), required_godot_version) + + +func make_godot_version_string(): + return VerNumTools.make_version_string(Engine.get_version_info()) diff --git a/addons/gut/version_numbers.gd.uid b/addons/gut/version_numbers.gd.uid new file mode 100644 index 0000000..3a2dac3 --- /dev/null +++ b/addons/gut/version_numbers.gd.uid @@ -0,0 +1 @@ +uid://b4bb6lchs5uba diff --git a/addons/gut/warnings_manager.gd b/addons/gut/warnings_manager.gd new file mode 100644 index 0000000..9e535c1 --- /dev/null +++ b/addons/gut/warnings_manager.gd @@ -0,0 +1,162 @@ +const IGNORE = 0 +const WARN = 1 +const ERROR = 2 + + +const WARNING_LOOKUP = { + IGNORE : 'IGNORE', + WARN : 'WARN', + ERROR : 'ERROR' +} + +const GDSCRIPT_WARNING = 'debug/gdscript/warnings/' + + +# --------------------------------------- +# Static +# --------------------------------------- +static var _static_init_called = false +# This is static and set in _static_init so that we can get the current settings as +# soon as possible. +static var _project_warnings : Dictionary = {} + +static var _disabled = false +# should never be true, unless it is, but it shouldn't be. Whatever it is, it +# should stay the same for the entire run. Read only. +static var disabled = _disabled: + get: return _disabled + set(val):pass + +static var project_warnings := {} : + get: + # somehow this gets called before _project_warnings is initialized when + # loading a project in the editor. It causes an error stating that + # duplicate can't be called on nil. It seems there might be an + # implicit "get" call happening. Using push_error I saw a message + # in this method, but not one from _static_init upon loading the project + if(_static_init_called): + return _project_warnings.duplicate() + else: + return {} + set(val): pass + + +static func _static_init(): + _project_warnings = create_warnings_dictionary_from_project_settings() + _static_init_called = true + if(disabled): + print(""" + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! Warnings Manager has been disabled + !! + !! Do not push this up buddy + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + """.dedent()) + + +static func are_warnings_enabled(): + return ProjectSettings.get(str(GDSCRIPT_WARNING, 'enable')) + + +## Turn all warnings on/off. Use reset_warnings to restore the original value. +static func enable_warnings(should=true): + if(disabled): + return + ProjectSettings.set(str(GDSCRIPT_WARNING, 'enable'), should) + + +## Turn on/off excluding addons. Use reset_warnings to restore the original value. +static func exclude_addons(should=true): + if(disabled): + return + ProjectSettings.set(str(GDSCRIPT_WARNING, 'exclude_addons'), should) + + +## Resets warning settings to what they are set to in Project Settings +static func reset_warnings(): + apply_warnings_dictionary(_project_warnings) + + + +static func set_project_setting_warning(warning_name : String, value : Variant): + if(disabled): + return + + var property_name = str(GDSCRIPT_WARNING, warning_name) + # This check will generate a warning if the setting does not exist + if(property_name in ProjectSettings): + ProjectSettings.set(property_name, value) + + +static func apply_warnings_dictionary(warning_values : Dictionary): + if(disabled): + return + + for key in warning_values: + set_project_setting_warning(key, warning_values[key]) + + +static func create_ignore_all_dictionary(): + return replace_warnings_values(project_warnings, -1, IGNORE) + + +static func create_warn_all_warnings_dictionary(): + return replace_warnings_values(project_warnings, -1, WARN) + + +static func replace_warnings_with_ignore(dict): + return replace_warnings_values(dict, WARN, IGNORE) + + +static func replace_errors_with_warnings(dict): + return replace_warnings_values(dict, ERROR, WARN) + + +static func replace_warnings_values(dict, replace_this, with_this): + var to_return = dict.duplicate() + for key in to_return: + if(typeof(to_return[key]) == TYPE_INT and (replace_this == -1 or to_return[key] == replace_this)): + to_return[key] = with_this + return to_return + + +static func create_warnings_dictionary_from_project_settings() -> Dictionary : + var props = ProjectSettings.get_property_list() + var to_return = {} + for i in props.size(): + if(props[i].name.begins_with(GDSCRIPT_WARNING)): + var prop_name = props[i].name.replace(GDSCRIPT_WARNING, '') + to_return[prop_name] = ProjectSettings.get(props[i].name) + return to_return + + +static func print_warnings_dictionary(which : Dictionary): + var is_valid = true + for key in which: + var value_str = str(which[key]) + if(_project_warnings.has(key)): + if(typeof(which[key]) == TYPE_INT): + if(WARNING_LOOKUP.has(which[key])): + value_str = WARNING_LOOKUP[which[key]] + else: + push_warning(str(which[key], ' is not a valid value for ', key)) + is_valid = false + else: + push_warning(str(key, ' is not a valid warning setting')) + is_valid = false + var s = str(key, ' = ', value_str) + print(s) + return is_valid + + +static func load_script_ignoring_all_warnings(path : String) -> Variant: + return load_script_using_custom_warnings(path, create_ignore_all_dictionary()) + + +static func load_script_using_custom_warnings(path : String, warnings_dictionary : Dictionary) -> Variant: + var current_warns = create_warnings_dictionary_from_project_settings() + apply_warnings_dictionary(warnings_dictionary) + var s = load(path) + apply_warnings_dictionary(current_warns) + + return s diff --git a/addons/gut/warnings_manager.gd.uid b/addons/gut/warnings_manager.gd.uid new file mode 100644 index 0000000..bb86e6f --- /dev/null +++ b/addons/gut/warnings_manager.gd.uid @@ -0,0 +1 @@ +uid://blo71surxlb13 diff --git a/docs/AI_docs/plan/chat_system.md b/docs/AI_docs/plan/chat_system.md index fd4e900..179c00a 100644 --- a/docs/AI_docs/plan/chat_system.md +++ b/docs/AI_docs/plan/chat_system.md @@ -408,17 +408,36 @@ ChatManager="*res://_Core/managers/ChatManager.gd" ### 1. AuthManager 集成 -**需求**: ChatManager 需要获取游戏 token +**需求**: ChatManager 需要获取 access_token 用于 WebSocket 聊天认证 -**解决方案**: 在 AuthManager 中添加方法 +**解决方案**: AuthManager 在登录成功后自动提取并保存 access_token 和 refresh_token + +**Token 管理架构**: ```gdscript -# AuthManager.gd - 添加此方法 -func get_game_token() -> String: - # 返回登录时保存的 token - return _game_token if _game_token != null else "" +# 内存存储(快速访问) +var _access_token: String = "" # JWT访问令牌(短期,用于API和WebSocket) +var _refresh_token: String = "" # JWT刷新令牌(长期,用于获取新access_token) +var _user_info: Dictionary = {} # 用户信息 +var _token_expiry: float = 0.0 # access_token过期时间(Unix时间戳) + +# 本地存储(ConfigFile持久化) +const AUTH_CONFIG_PATH: String = "user://auth.cfg" ``` -**注意事项**: 需要在 `/auth/login` 成功后保存 token 到 AuthManager +**登录流程**: +1. 用户登录成功后,服务器返回 `access_token` 和 `refresh_token` +2. AuthManager 调用 `_save_tokens_to_memory(data)` 保存到内存 +3. AuthManager 调用 `_save_tokens_to_local(data)` 保存到本地ConfigFile +4. AuthScene 在登录成功后调用 `ChatManager.set_game_token(token)` 设置token + +**Token 存储内容**: +- **内存**: access_token, refresh_token, user_info, token_expiry +- **本地 (user://auth.cfg)**: refresh_token, user_id, username, saved_at + +**注意事项**: +- `access_token` 仅保存在内存中,不存储到本地(安全考虑) +- `refresh_token` 加密存储到本地,用于下次登录时自动刷新token +- Token 过期后需要使用 refresh_token 刷新 ### 2. EventSystem 集成 @@ -455,7 +474,8 @@ func _ready(): #### 1. 登录 ```json // 发送 -{"type": "login", "token": "user_game_token"} +// token 字段应该使用登录接口返回的 access_token +{"type": "login", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."} // 成功响应 {"t": "login_success", "sessionId": "...", "currentMap": "...", "username": "..."} @@ -464,6 +484,12 @@ func _ready(): {"t": "error", "code": "AUTH_FAILED", "message": "..."} ``` +**Token 来源**: +- 登录接口 (`/auth/login`) 返回 `access_token` (JWT访问令牌) +- AuthManager 在登录成功后保存 access_token 到内存 +- AuthScene 在登录成功后设置 token 给 ChatManager +- ChatManager 使用该 token 发送 WebSocket 登录消息 + #### 2. 发送聊天 ```json // 发送 diff --git a/project.godot b/project.godot index c88b107..124f307 100644 --- a/project.godot +++ b/project.godot @@ -83,8 +83,6 @@ locale/test="zh_CN" [rendering] -renderer/rendering_method="gl_compatibility" -renderer/rendering_method.mobile="gl_compatibility" textures/vram_compression/import_etc2_astc=true fonts/dynamic_fonts/use_oversampling=true debug/disable_vsync=false diff --git a/scenes/MainScene.gd b/scenes/MainScene.gd index b1b7f24..2bb3566 100644 --- a/scenes/MainScene.gd +++ b/scenes/MainScene.gd @@ -14,6 +14,7 @@ extends Control # 场景节点引用 @onready var auth_scene: Control = $AuthScene @onready var main_game_ui: Control = $MainGameUI +@onready var chat_ui: Control = %ChatUI @onready var user_label: Label = $MainGameUI/TopBar/HBoxContainer/UserLabel @onready var logout_button: Button = $MainGameUI/TopBar/HBoxContainer/LogoutButton @@ -87,11 +88,21 @@ func _on_login_success(username: String): # 登录成功后的处理 current_user = username print("用户 ", username, " 登录成功!") + + # 连接到聊天服务器(在进入游戏界面之前) + print("🔌 开始连接聊天服务器...") + ChatManager.connect_to_chat_server() + show_main_game() func _on_logout_pressed(): # 登出处理 current_user = "" + + # 断开聊天服务器连接 + print("🔌 断开聊天服务器...") + ChatManager.disconnect_from_chat_server() + show_auth_scene() # 游戏功能按钮处理 diff --git a/scenes/MainScene.tscn b/scenes/MainScene.tscn index 4162235..c238e9f 100644 --- a/scenes/MainScene.tscn +++ b/scenes/MainScene.tscn @@ -2,6 +2,7 @@ [ext_resource type="Script" path="res://scenes/MainScene.gd" id="1_script"] [ext_resource type="PackedScene" uid="uid://by7m8snb4xllf" path="res://scenes/ui/AuthScene.tscn" id="2_main"] +[ext_resource type="PackedScene" uid="uid://bv7k2nan4xj8q" path="res://scenes/ui/ChatUI.tscn" id="3_chat_ui"] [node name="Main" type="Control"] layout_mode = 3 diff --git a/scenes/ui/AuthScene.gd b/scenes/ui/AuthScene.gd index b9b1873..a8a36ac 100644 --- a/scenes/ui/AuthScene.gd +++ b/scenes/ui/AuthScene.gd @@ -328,7 +328,7 @@ func _on_login_enter(_text: String): # ============ 控制器信号处理 ============ # 登录成功处理 -func _on_controller_login_success(username: String): +func _on_controller_login_success(username: String) -> void: # 清空表单 login_username.text = "" login_password.text = "" @@ -336,7 +336,13 @@ func _on_controller_login_success(username: String): _hide_field_error(login_username_error) _hide_field_error(login_password_error) _hide_field_error(login_verification_error) - + + # 设置 token 给 ChatManager(用于 WebSocket 聊天认证) + var token: String = auth_manager.get_access_token() + if not token.is_empty(): + ChatManager.set_game_token(token) + print("✅ 已设置 ChatManager token: ", token.substr(0, 20) + "...") + # 发送登录成功信号给上层 login_success.emit(username) diff --git a/scenes/ui/ChatUI.gd b/scenes/ui/ChatUI.gd index 32d8212..1a86109 100644 --- a/scenes/ui/ChatUI.gd +++ b/scenes/ui/ChatUI.gd @@ -297,16 +297,17 @@ func _on_chat_error(data: Dictionary) -> void: # 3秒后恢复状态 var timer := get_tree().create_timer(3.0) - timer.timeout.connect(func(): - _update_connection_status(ChatManager.is_connected()) - ) + var timeout_callback := func(): + _update_connection_status(ChatManager.is_chat_connected()) + timer.timeout.connect(timeout_callback) # 处理连接状态变化 func _on_connection_state_changed(data: Dictionary) -> void: - var state: WebSocketManager.ConnectionState = data.get("state", WebSocketManager.ConnectionState.DISCONNECTED) + var state_names := ["DISCONNECTED", "CONNECTING", "CONNECTED", "RECONNECTING", "ERROR"] + var state: int = data.get("state", 0) match state: - WebSocketManager.ConnectionState.CONNECTED: + 2: # CONNECTED _update_connection_status(true) _: _update_connection_status(false) diff --git a/scenes/ui/ChatUI.tscn b/scenes/ui/ChatUI.tscn index e3c1474..d312dc4 100644 --- a/scenes/ui/ChatUI.tscn +++ b/scenes/ui/ChatUI.tscn @@ -1,8 +1,10 @@ -[gd_scene load_steps=2 format=3 uid="uid://bv7k2m9n4xj8q"] +[gd_scene load_steps=3 format=3 uid="uid://bv7k2nan4xj8q"] -[ext_resource type="Script" path="res://scenes/ui/ChatUI.gd" id="1"] +[ext_resource type="Script" uid="uid://pibdlvhb12q8" path="res://scenes/ui/ChatUI.gd" id="1"] +[ext_resource type="Texture2D" uid="uid://flepo0gpb55h" path="res://assets/ui/chat/缩略框背景.png" id="2_7dhmv"] [node name="ChatUI" type="Control"] +custom_minimum_size = Vector2(10, 20) layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 @@ -10,19 +12,28 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 +unique_name_in_owner = true script = ExtResource("1") +[node name="TextureRect" type="TextureRect" parent="."] +layout_mode = 0 +offset_left = 10.0 +offset_top = 358.0 +offset_right = 460.0 +offset_bottom = 758.0 +texture = ExtResource("2_7dhmv") +expand_mode = 1 + [node name="ChatPanel" type="Panel" parent="."] unique_name_in_owner = true layout_mode = 1 -anchors_preset = 3 -anchor_left = 1.0 +anchors_preset = 2 anchor_top = 1.0 -anchor_right = 1.0 anchor_bottom = 1.0 -offset_left = -450.0 -offset_top = -400.0 -grow_horizontal = 0 +offset_left = 10.0 +offset_top = -410.0 +offset_right = 460.0 +offset_bottom = -10.0 grow_vertical = 0 [node name="VBoxContainer" type="VBoxContainer" parent="ChatPanel"] @@ -30,13 +41,13 @@ layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 -theme_override_constants/separation = 8 offset_left = 10.0 offset_top = 10.0 offset_right = -10.0 offset_bottom = -10.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 8 [node name="HeaderContainer" type="HBoxContainer" parent="ChatPanel/VBoxContainer"] layout_mode = 2 diff --git a/tests/unit/test_chat_manager.gd b/tests/unit/test_chat_manager.gd index 768e0ef..630d39f 100644 --- a/tests/unit/test_chat_manager.gd +++ b/tests/unit/test_chat_manager.gd @@ -40,7 +40,7 @@ func test_chat_manager_initialization(): assert_not_null(chat_manager, "ChatManager 应该成功初始化") assert_not_null(chat_manager._websocket_manager, "WebSocket 管理器应该被创建") assert_not_null(chat_manager._socket_client, "Socket.IO 客户端应该被创建") - assert_false(chat_manager.is_connected(), "初始状态应该是未连接") + assert_false(chat_manager.is_chat_connected(), "初始状态应该是未连接") # ============================================================================ # Token 管理测试 diff --git a/tests/unit/test_socketio_client.gd b/tests/unit/test_socketio_client.gd index 7c6adae..ab406c3 100644 --- a/tests/unit/test_socketio_client.gd +++ b/tests/unit/test_socketio_client.gd @@ -36,7 +36,7 @@ func after_each(): func test_socket_initialization(): # 测试客户端初始化 assert_not_null(socket_client, "SocketIOClient 应该成功初始化") - assert_eq(socket_client.is_connected(), false, "初始状态应该是未连接") + assert_eq(socket_client.is_socket_connected(), false, "初始状态应该是未连接") # ============================================================================ # 连接状态测试 diff --git a/tests/unit/test_websocket_manager.gd b/tests/unit/test_websocket_manager.gd index 1ff450c..e2cc028 100644 --- a/tests/unit/test_websocket_manager.gd +++ b/tests/unit/test_websocket_manager.gd @@ -38,7 +38,7 @@ func test_websocket_manager_initialization(): # 测试管理器初始化 assert_not_null(ws_manager, "WebSocketManager 应该成功初始化") assert_not_null(ws_manager._socket_client, "Socket.IO 客户端应该被创建") - assert_eq(ws_manager.is_connected(), false, "初始状态应该是未连接") + assert_eq(ws_manager.is_websocket_connected(), false, "初始状态应该是未连接") func test_initial_connection_state(): # 测试初始连接状态 @@ -149,7 +149,7 @@ func test_get_socket_client(): var socket_client := ws_manager.get_socket_client() assert_not_null(socket_client, "应该返回 Socket.IO 客户端") - assert_is_instanceof(socket_client, SocketIOClient, + assert_true(socket_client is SocketIOClient, "返回的应该是 SocketIOClient 实例") # ============================================================================ @@ -241,7 +241,7 @@ func test_zero_base_delay(): var delay := ws_manager._calculate_reconnect_delay() # 即使基础延迟为 0,也应该返回一个合理的值 - assert_ge(delay, 0.0, "延迟应该 >= 0") + assert_true(delay >= 0.0, "延迟应该 >= 0") # ============================================================================ # 状态转换测试