Files
settled-reach/client/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd
T
jpmschweitzerandClaude Opus 4.6 61e0762a84 chore(client): add gdUnit4 test framework addon
Vendor gdUnit4 for Godot 4 client testing (D-030).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:52:00 +01:00

1211 lines
41 KiB
GDScript

@tool
extends VSplitContainer
## Will be emitted when the test index counter is changed
signal test_counters_changed(index: int, total: int, state: GdUnitInspectorTreeConstants.STATE)
signal tree_item_selected(item: TreeItem)
@onready var _tree: Tree = $Panel/Tree
@onready var _report_list: Node = $report/ScrollContainer/list
@onready var _report_template: RichTextLabel = $report/report_template
@onready var _context_menu: GdUnitInspectorContextMenu = $contextMenu
@onready var _discover_hint: Control = %discover_hint
@onready var _spinner: Button = %spinner
# loading tree icons
@onready var ICON_SPINNER := GdUnitUiTools.get_spinner()
@onready var ICON_FOLDER := GdUnitUiTools.get_icon("Folder")
# gdscript icons
@onready var ICON_GDSCRIPT_TEST_DEFAULT := GdUnitUiTools.get_icon("GDScript", Color.LIGHT_GRAY)
@onready var ICON_GDSCRIPT_TEST_SUCCESS := GdUnitUiTools.get_GDScript_icon("StatusSuccess", Color.DARK_GREEN)
@onready var ICON_GDSCRIPT_TEST_FLAKY := GdUnitUiTools.get_GDScript_icon("CheckBox", Color.GREEN_YELLOW)
@onready var ICON_GDSCRIPT_TEST_FAILED := GdUnitUiTools.get_GDScript_icon("StatusError", Color.SKY_BLUE)
@onready var ICON_GDSCRIPT_TEST_ERROR := GdUnitUiTools.get_GDScript_icon("StatusError", Color.DARK_RED)
@onready var ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN := GdUnitUiTools.get_GDScript_icon("Unlinked", Color.DARK_GREEN)
@onready var ICON_GDSCRIPT_TEST_FAILED_ORPHAN := GdUnitUiTools.get_GDScript_icon("Unlinked", Color.SKY_BLUE)
@onready var ICON_GDSCRIPT_TEST_ERRORS_ORPHAN := GdUnitUiTools.get_GDScript_icon("Unlinked", Color.DARK_RED)
# csharp script icons
@onready var ICON_CSSCRIPT_TEST_DEFAULT := GdUnitUiTools.get_icon("CSharpScript", Color.LIGHT_GRAY)
@onready var ICON_CSSCRIPT_TEST_SUCCESS := GdUnitUiTools.get_CSharpScript_icon("StatusSuccess", Color.DARK_GREEN)
@onready var ICON_CSSCRIPT_TEST_FAILED := GdUnitUiTools.get_CSharpScript_icon("StatusError", Color.SKY_BLUE)
@onready var ICON_CSSCRIPT_TEST_ERROR := GdUnitUiTools.get_CSharpScript_icon("StatusError", Color.DARK_RED)
@onready var ICON_CSSCRIPT_TEST_SUCCESS_ORPHAN := GdUnitUiTools.get_CSharpScript_icon("Unlinked", Color.DARK_GREEN)
@onready var ICON_CSSCRIPT_TEST_FAILED_ORPHAN := GdUnitUiTools.get_CSharpScript_icon("Unlinked", Color.SKY_BLUE)
@onready var ICON_CSSCRIPT_TEST_ERRORS_ORPHAN := GdUnitUiTools.get_CSharpScript_icon("Unlinked", Color.DARK_RED)
enum GdUnitType {
FOLDER,
TEST_SUITE,
TEST_CASE,
TEST_GROUP
}
const META_GDUNIT_PROGRESS_COUNT_MAX := "gdUnit_progress_count_max"
const META_GDUNIT_PROGRESS_INDEX := "gdUnit_progress_index"
const META_TEST_CASE := "gdunit_test_case"
const META_GDUNIT_NAME := "gdUnit_name"
const META_GDUNIT_STATE := "gdUnit_state"
const META_GDUNIT_TYPE := "gdUnit_type"
const META_GDUNIT_SUCCESS_TESTS := "gdUnit_suite_success_tests"
const META_GDUNIT_REPORT := "gdUnit_report"
const META_GDUNIT_ORPHAN := "gdUnit_orphan"
const META_GDUNIT_EXECUTION_TIME := "gdUnit_execution_time"
const META_GDUNIT_ORIGINAL_INDEX = "gdunit_original_index"
const STATE = GdUnitInspectorTreeConstants.STATE
var _tree_root: TreeItem
var _current_selected_item: TreeItem = null
var _current_tree_view_mode := GdUnitSettings.get_inspector_tree_view_mode()
var _run_test_recovery := true
## Used for debugging purposes only
func print_tree_item_ids(parent: TreeItem) -> TreeItem:
for child in parent.get_children():
if child.has_meta(META_TEST_CASE):
var test_case: GdUnitTestCase = child.get_meta(META_TEST_CASE)
prints(test_case.guid, test_case.test_name)
if child.get_child_count() > 0:
print_tree_item_ids(child)
return null
func _find_tree_item(parent: TreeItem, item_name: String) -> TreeItem:
for child in parent.get_children():
if child.get_meta(META_GDUNIT_NAME) == item_name:
return child
return null
func _find_tree_item_by_id(parent: TreeItem, id: GdUnitGUID) -> TreeItem:
for child in parent.get_children():
if is_test_id(child, id):
return child
if child.get_child_count() > 0:
var item := _find_tree_item_by_id(child, id)
if item != null:
return item
return null
func _find_tree_item_by_test_suite(parent: TreeItem, suite_path: String, suite_name: String) -> TreeItem:
for child in parent.get_children():
if child.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_SUITE:
var test_case: GdUnitTestCase = child.get_meta(META_TEST_CASE)
if test_case.suite_resource_path == suite_path and test_case.suite_name == suite_name:
return child
if child.get_child_count() > 0:
var item := _find_tree_item_by_test_suite(child, suite_path, suite_name)
if item != null:
return item
return null
func _find_first_item_by_state(parent: TreeItem, item_state: STATE, reverse := false) -> TreeItem:
var itmes := parent.get_children()
if reverse:
itmes.reverse()
for item in itmes:
if is_test_case(item) and (is_item_state(item, item_state)):
return item
var failure_item := _find_first_item_by_state(item, item_state, reverse)
if failure_item != null:
return failure_item
return null
func _find_last_item_by_state(parent: TreeItem, item_state: STATE) -> TreeItem:
return _find_first_item_by_state(parent, item_state, true)
func _find_item_by_state(current: TreeItem, item_state: STATE, prev := false) -> TreeItem:
var next := current.get_prev_in_tree() if prev else current.get_next_in_tree()
if next == null or next == _tree_root:
return null
if is_test_case(next) and is_item_state(next, item_state):
return next
return _find_item_by_state(next, item_state, prev)
func is_item_state(item: TreeItem, item_state: STATE) -> bool:
return item.has_meta(META_GDUNIT_STATE) and item.get_meta(META_GDUNIT_STATE) == item_state
func is_state_running(item: TreeItem) -> bool:
return is_item_state(item, STATE.RUNNING)
func is_state_success(item: TreeItem) -> bool:
return is_item_state(item, STATE.SUCCESS)
func is_state_warning(item: TreeItem) -> bool:
return is_item_state(item, STATE.WARNING)
func is_state_failed(item: TreeItem) -> bool:
return is_item_state(item, STATE.FAILED)
func is_state_error(item: TreeItem) -> bool:
return is_item_state(item, STATE.ERROR) or is_item_state(item, STATE.ABORDED)
func is_item_state_orphan(item: TreeItem) -> bool:
return item.has_meta(META_GDUNIT_ORPHAN)
func is_test_suite(item: TreeItem) -> bool:
return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_SUITE
func is_test_case(item: TreeItem) -> bool:
return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_CASE
func is_folder(item: TreeItem) -> bool:
return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.FOLDER
func is_test_id(item: TreeItem, id: GdUnitGUID) -> bool:
if not item.has_meta(META_TEST_CASE):
return false
var test_case: GdUnitTestCase = item.get_meta(META_TEST_CASE)
return test_case.guid.equals(id)
func disable_test_recovery() -> void:
_run_test_recovery = false
@warning_ignore("return_value_discarded")
func _ready() -> void:
if Engine.is_editor_hint():
var base_control := EditorInterface.get_base_control()
base_control.set_meta("GdUnit4Inspector", self)
_spinner.icon = GdUnitUiTools.get_spinner()
init_tree()
GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed)
GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event)
GdUnitSignals.instance().gdunit_test_discover_added.connect(on_test_case_discover_added)
GdUnitSignals.instance().gdunit_test_discover_deleted.connect(on_test_case_discover_deleted)
GdUnitSignals.instance().gdunit_test_discover_modified.connect(on_test_case_discover_modified)
if _run_test_recovery:
GdUnitTestDiscoverer.restore_last_session()
# we need current to manually redraw bacause of the animation bug
# https://github.com/godotengine/godot/issues/69330
func _process(_delta: float) -> void:
if is_visible_in_tree():
queue_redraw()
func init_tree() -> void:
cleanup_tree()
_tree.deselect_all()
_tree.set_hide_root(true)
_tree.ensure_cursor_is_visible()
_tree.set_allow_reselect(true)
_tree.set_allow_rmb_select(true)
_tree.set_columns(2)
_tree.set_column_clip_content(0, true)
_tree.set_column_expand_ratio(0, 1)
_tree.set_column_custom_minimum_width(0, 240)
_tree.set_column_expand_ratio(1, 0)
_tree.set_column_custom_minimum_width(1, 100)
_tree_root = _tree.create_item()
_tree_root.set_text(0, "tree_root")
_tree_root.set_meta(META_GDUNIT_NAME, "tree_root")
_tree_root.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0)
_tree_root.set_meta(META_GDUNIT_PROGRESS_INDEX, 0)
_tree_root.set_meta(META_GDUNIT_STATE, STATE.INITIAL)
_tree_root.set_meta(META_GDUNIT_SUCCESS_TESTS, 0)
# fix tree icon scaling
var scale_factor := EditorInterface.get_editor_scale() if Engine.is_editor_hint() else 1.0
_tree.set("theme_override_constants/icon_max_width", 16 * scale_factor)
func cleanup_tree() -> void:
clear_reports()
if not _tree_root:
return
_free_recursive()
_tree.clear()
_current_selected_item = null
func _free_recursive(items:=_tree_root.get_children()) -> void:
for item in items:
_free_recursive(item.get_children())
item.call_deferred("free")
func sort_tree_items(parent: TreeItem) -> void:
_sort_tree_items(parent, GdUnitSettings.get_inspector_tree_sort_mode())
_tree.queue_redraw()
static func _sort_tree_items(parent: TreeItem, sort_mode: GdUnitInspectorTreeConstants.SORT_MODE) -> void:
parent.visible = false
var items := parent.get_children()
# first remove all childs before sorting
for item in items:
parent.remove_child(item)
# do sort by selected sort mode
match sort_mode:
GdUnitInspectorTreeConstants.SORT_MODE.UNSORTED:
items.sort_custom(sort_items_by_original_index)
GdUnitInspectorTreeConstants.SORT_MODE.NAME_ASCENDING:
items.sort_custom(sort_items_by_name.bind(true))
GdUnitInspectorTreeConstants.SORT_MODE.NAME_DESCENDING:
items.sort_custom(sort_items_by_name.bind(false))
GdUnitInspectorTreeConstants.SORT_MODE.EXECUTION_TIME:
items.sort_custom(sort_items_by_execution_time)
# readding sorted childs
for item in items:
parent.add_child(item)
if item.get_child_count() > 0:
_sort_tree_items(item, sort_mode)
parent.visible = true
static func sort_items_by_name(a: TreeItem, b: TreeItem, ascending: bool) -> bool:
var type_a: GdUnitType = a.get_meta(META_GDUNIT_TYPE)
var type_b: GdUnitType = b.get_meta(META_GDUNIT_TYPE)
# Sort folders to the top
if type_a == GdUnitType.FOLDER and type_b != GdUnitType.FOLDER:
return true
if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER:
return false
# sort by name
var name_a: String = a.get_meta(META_GDUNIT_NAME)
var name_b: String = b.get_meta(META_GDUNIT_NAME)
var comparison := name_a.naturalnocasecmp_to(name_b)
return comparison < 0 if ascending else comparison > 0
static func sort_items_by_execution_time(a: TreeItem, b: TreeItem) -> bool:
var type_a: GdUnitType = a.get_meta(META_GDUNIT_TYPE)
var type_b: GdUnitType = b.get_meta(META_GDUNIT_TYPE)
# Sort folders to the top
if type_a == GdUnitType.FOLDER and type_b != GdUnitType.FOLDER:
return true
if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER:
return false
var execution_time_a :int = a.get_meta(META_GDUNIT_EXECUTION_TIME)
var execution_time_b :int = b.get_meta(META_GDUNIT_EXECUTION_TIME)
# if has same execution time sort by name
if execution_time_a == execution_time_b:
var name_a :String = a.get_meta(META_GDUNIT_NAME)
var name_b :String = b.get_meta(META_GDUNIT_NAME)
return name_a.naturalnocasecmp_to(name_b) > 0
return execution_time_a > execution_time_b
static func sort_items_by_original_index(a: TreeItem, b: TreeItem) -> bool:
var type_a: GdUnitType = a.get_meta(META_GDUNIT_TYPE)
var type_b: GdUnitType = b.get_meta(META_GDUNIT_TYPE)
# Sort folders to the top
if type_a == GdUnitType.FOLDER and type_b != GdUnitType.FOLDER:
return true
if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER:
return false
var index_a :int = a.get_meta(META_GDUNIT_ORIGINAL_INDEX)
var index_b :int = b.get_meta(META_GDUNIT_ORIGINAL_INDEX)
# Sorting by index
return index_a < index_b
func restructure_tree(parent: TreeItem, tree_mode: GdUnitInspectorTreeConstants.TREE_VIEW_MODE) -> void:
_current_tree_view_mode = tree_mode
match tree_mode:
GdUnitInspectorTreeConstants.TREE_VIEW_MODE.FLAT:
restructure_tree_to_flat(parent)
GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE:
restructure_tree_to_tree(parent)
recalculate_counters(_tree_root)
# finally apply actual sort mode
sort_tree_items(_tree_root)
# Restructure into flat mode
func restructure_tree_to_flat(parent: TreeItem) -> void:
var folders := flatmap_folders(parent)
# Store current folder paths and their test suites
for folder_path: String in folders:
var test_suites: Array[TreeItem] = folders[folder_path]
if test_suites.is_empty():
continue
# Create flat folder and move test suites into it
var folder := _tree.create_item(parent)
folder.set_meta(META_GDUNIT_NAME, folder_path)
update_item_total_counter(folder)
set_state_initial(folder, GdUnitType.FOLDER)
# Move test suites under the flat folder
for test_suite in test_suites:
var old_parent := test_suite.get_parent()
old_parent.remove_child(test_suite)
folder.add_child(test_suite)
# Cleanup old folder structure
cleanup_empty_folders(parent)
# Restructure into hierarchical tree mode
func restructure_tree_to_tree(parent: TreeItem) -> void:
var items_to_process := parent.get_children().duplicate()
for item: TreeItem in items_to_process:
if is_folder(item):
var folder_path: String = item.get_meta(META_GDUNIT_NAME)
var parts := folder_path.split("/")
if parts.size() > 1:
var current_parent := parent
# Build folder hierarchy
for part in parts:
var next := _find_tree_item(current_parent, part)
if not next:
next = _tree.create_item(current_parent)
next.set_meta(META_GDUNIT_NAME, part)
set_state_initial(next, GdUnitType.FOLDER)
current_parent = next
# Move test suites to deepest folder
var test_suites := item.get_children()
for test_suite in test_suites:
item.remove_child(test_suite)
current_parent.add_child(test_suite)
# Remove the flat folder
item.get_parent().remove_child(item)
item.free()
func flatmap_folders(parent: TreeItem) -> Dictionary:
var folder_map := {}
for item in parent.get_children():
if is_folder(item):
var current_path: String = item.get_meta(META_GDUNIT_NAME)
# Get parent folder paths
var parent_path := get_parent_folder_path(item)
if parent_path:
current_path = parent_path + "/" + current_path
# Collect direct children of this folder
var children: Array[TreeItem] = []
for child in item.get_children():
if is_test_suite(child):
children.append(child)
# Add children to existing path or create new entry
if not children.is_empty():
if folder_map.has(current_path):
@warning_ignore("unsafe_method_access")
folder_map[current_path].append_array(children)
else:
folder_map[current_path] = children
# Recursively process subfolders
var sub_folders := flatmap_folders(item)
for path: String in sub_folders.keys():
if folder_map.has(path):
@warning_ignore("unsafe_method_access")
folder_map[path].append_array(sub_folders[path])
else:
folder_map[path] = sub_folders[path]
return folder_map
func get_parent_folder_path(item: TreeItem) -> String:
var path := ""
var parent := item.get_parent()
while parent != _tree_root:
if is_folder(parent):
path = parent.get_meta(META_GDUNIT_NAME) + ("/" + path if path else "")
parent = parent.get_parent()
return path
func cleanup_empty_folders(parent: TreeItem) -> void:
var folders: Array[TreeItem] = []
# First collect all folders to avoid modification during iteration
for item in parent.get_children():
if is_folder(item):
folders.append(item)
# Process collected folders
for folder in folders:
cleanup_empty_folders(folder)
# Remove folder if it has no children after cleanup
if folder.get_child_count() == 0:
parent.remove_child(folder)
folder.free()
func reset_tree_state(parent: TreeItem) -> void:
if parent == _tree_root:
_tree_root.set_meta(META_GDUNIT_PROGRESS_INDEX, 0)
_tree_root.set_meta(META_GDUNIT_STATE, STATE.INITIAL)
test_counters_changed.emit(0, 0, STATE.INITIAL)
for item in parent.get_children():
set_state_initial(item, get_item_type(item))
reset_tree_state(item)
func select_item(item: TreeItem) -> TreeItem:
if item != null:
# enshure the parent is collapsed
do_collapse_parent(item)
item.select(0)
_tree.ensure_cursor_is_visible()
_tree.scroll_to_item(item, true)
return item
func do_collapse_parent(item: TreeItem) -> void:
if item != null:
item.collapsed = false
do_collapse_parent(item.get_parent())
func do_collapse_all(collapse: bool, parent := _tree_root) -> void:
for item in parent.get_children():
item.collapsed = collapse
if not collapse:
do_collapse_all(collapse, item)
func set_state_initial(item: TreeItem, type: GdUnitType) -> void:
item.set_text(0, str(item.get_meta(META_GDUNIT_NAME)))
item.set_custom_color(0, Color.LIGHT_GRAY)
item.set_tooltip_text(0, "")
item.set_text_overrun_behavior(0, TextServer.OVERRUN_TRIM_CHAR)
item.set_expand_right(0, true)
item.set_custom_color(1, Color.LIGHT_GRAY)
item.set_text(1, "")
item.set_expand_right(1, true)
item.set_tooltip_text(1, "")
item.set_meta(META_GDUNIT_STATE, STATE.INITIAL)
item.set_meta(META_GDUNIT_TYPE, type)
item.set_meta(META_GDUNIT_SUCCESS_TESTS, 0)
item.set_meta(META_GDUNIT_EXECUTION_TIME, 0)
if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX) and item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX) > 0:
item.set_text(0, "(0/%d) %s" % [item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)])
item.remove_meta(META_GDUNIT_REPORT)
item.remove_meta(META_GDUNIT_ORPHAN)
set_item_icon_by_state(item)
func set_state_running(item: TreeItem, is_running: bool) -> void:
if is_state_running(item):
return
if is_item_state(item, STATE.INITIAL):
item.set_custom_color(0, Color.DARK_GREEN)
item.set_custom_color(1, Color.DARK_GREEN)
item.set_meta(META_GDUNIT_STATE, STATE.RUNNING)
item.collapsed = false
if is_running:
item.set_icon(0, ICON_SPINNER)
else:
set_item_icon_by_state(item)
for child in item.get_children():
set_item_icon_by_state(child)
var parent := item.get_parent()
if parent != _tree_root:
set_state_running(parent, is_running)
func set_state_succeded(item: TreeItem) -> void:
# Do not overwrite higher states
if is_state_error(item) or is_state_failed(item):
return
if item == _tree_root:
return
item.set_custom_color(0, Color.GREEN)
item.set_custom_color(1, Color.GREEN)
item.set_meta(META_GDUNIT_STATE, STATE.SUCCESS)
item.collapsed = GdUnitSettings.is_inspector_node_collapse()
set_item_icon_by_state(item)
func set_state_flaky(item: TreeItem, event: GdUnitEvent) -> void:
# Do not overwrite higher states
if is_state_error(item):
return
var retry_count := event.statistic(GdUnitEvent.RETRY_COUNT)
item.set_meta(META_GDUNIT_STATE, STATE.FLAKY)
if retry_count > 1:
var item_text: String = item.get_meta(META_GDUNIT_NAME)
if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX):
var success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS)
item_text = "(%d/%d) %s" % [success_count, item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)]
item.set_text(0, "%s (%s retries)" % [item_text, retry_count])
item.set_custom_color(0, Color.GREEN_YELLOW)
item.set_custom_color(1, Color.GREEN_YELLOW)
item.collapsed = false
set_item_icon_by_state(item)
func set_state_skipped(item: TreeItem) -> void:
item.set_meta(META_GDUNIT_STATE, STATE.SKIPPED)
item.set_text(1, "(skipped)")
item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT)
item.set_custom_color(0, Color.DARK_GRAY)
item.set_custom_color(1, Color.DARK_GRAY)
item.collapsed = false
set_item_icon_by_state(item)
func set_state_warnings(item: TreeItem) -> void:
# Do not overwrite higher states
if is_state_error(item) or is_state_failed(item):
return
item.set_meta(META_GDUNIT_STATE, STATE.WARNING)
item.set_custom_color(0, Color.YELLOW)
item.set_custom_color(1, Color.YELLOW)
item.collapsed = false
set_item_icon_by_state(item)
func set_state_failed(item: TreeItem, event: GdUnitEvent) -> void:
# Do not overwrite higher states
if is_state_error(item):
return
var retry_count := event.statistic(GdUnitEvent.RETRY_COUNT)
if retry_count > 1:
var item_text: String = item.get_meta(META_GDUNIT_NAME)
if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX):
var success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS)
item_text = "(%d/%d) %s" % [success_count, item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)]
item.set_text(0, "%s (%s retries)" % [item_text, retry_count])
item.set_meta(META_GDUNIT_STATE, STATE.FAILED)
item.set_custom_color(0, Color.LIGHT_BLUE)
item.set_custom_color(1, Color.LIGHT_BLUE)
item.collapsed = false
set_item_icon_by_state(item)
func set_state_error(item: TreeItem) -> void:
item.set_meta(META_GDUNIT_STATE, STATE.ERROR)
item.set_custom_color(0, Color.ORANGE_RED)
item.set_custom_color(1, Color.ORANGE_RED)
set_item_icon_by_state(item)
item.collapsed = false
func set_state_aborted(item: TreeItem) -> void:
item.set_meta(META_GDUNIT_STATE, STATE.ABORDED)
item.set_custom_color(0, Color.ORANGE_RED)
item.set_custom_color(1, Color.ORANGE_RED)
item.clear_custom_bg_color(0)
item.set_text(1, "(aborted)")
item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT)
set_item_icon_by_state(item)
item.collapsed = false
func set_state_orphan(item: TreeItem, event: GdUnitEvent) -> void:
var orphan_count := event.statistic(GdUnitEvent.ORPHAN_NODES)
if orphan_count == 0:
return
if item.has_meta(META_GDUNIT_ORPHAN):
orphan_count += item.get_meta(META_GDUNIT_ORPHAN)
item.set_meta(META_GDUNIT_ORPHAN, orphan_count)
if item.get_meta(META_GDUNIT_STATE) != STATE.FAILED:
item.set_custom_color(0, Color.YELLOW)
item.set_custom_color(1, Color.YELLOW)
item.set_tooltip_text(0, "Total <%d> orphan nodes detected." % orphan_count)
set_item_icon_by_state(item)
func update_state(item: TreeItem, event: GdUnitEvent, add_reports := true) -> void:
# we do not show the root
if item == null:
return
if event.is_skipped():
set_state_skipped(item)
elif event.is_success() and event.is_flaky():
set_state_flaky(item, event)
elif event.is_success():
set_state_succeded(item)
elif event.is_error():
set_state_error(item)
elif event.is_failed():
set_state_failed(item, event)
elif event.is_warning():
set_state_warnings(item)
if add_reports:
for report in event.reports():
add_report(item, report)
set_state_orphan(item, event)
var parent := item.get_parent()
if parent == null:
return
var item_state: int = item.get_meta(META_GDUNIT_STATE)
var parent_state: int = parent.get_meta(META_GDUNIT_STATE)
if item_state <= parent_state:
return
update_state(item.get_parent(), event, false)
func add_report(item: TreeItem, report: GdUnitReport) -> void:
var reports: Array[GdUnitReport] = []
if item.has_meta(META_GDUNIT_REPORT):
reports = get_item_reports(item)
reports.append(report)
item.set_meta(META_GDUNIT_REPORT, reports)
func abort_running(items:=_tree_root.get_children()) -> void:
for item in items:
if item.get_icon(0) == ICON_SPINNER:
set_state_aborted(item)
abort_running(item.get_children())
func _on_select_next_item_by_state(item_state: int) -> TreeItem:
var current_selected := _tree.get_selected()
# If nothing is selected, the first error is selected or the next one in the vicinity of the current selection is found
current_selected = _find_first_item_by_state(_tree_root, item_state) if current_selected == null else _find_item_by_state(current_selected, item_state)
# If no next failure found, then we try to select first
if current_selected == null:
current_selected = _find_first_item_by_state(_tree_root, item_state)
return select_item(current_selected)
func _on_select_previous_item_by_state(item_state: int) -> TreeItem:
var current_selected := _tree.get_selected()
# If nothing is selected, the first error is selected or the next one in the vicinity of the current selection is found
current_selected = _find_last_item_by_state(_tree_root, item_state) if current_selected == null else _find_item_by_state(current_selected, item_state, true)
# If no next failure found, then we try to select first last
if current_selected == null:
current_selected = _find_last_item_by_state(_tree_root, item_state)
return select_item(current_selected)
func select_first_orphan() -> void:
for parent in _tree_root.get_children():
if not is_state_success(parent):
for item in parent.get_children():
if is_item_state_orphan(item):
parent.set_collapsed(false)
@warning_ignore("return_value_discarded")
select_item(item)
return
func clear_reports() -> void:
for child in _report_list.get_children():
_report_list.remove_child(child)
child.queue_free()
func show_failed_report(selected_item: TreeItem) -> void:
clear_reports()
if selected_item == null or not selected_item.has_meta(META_GDUNIT_REPORT):
return
# add new reports
for report in get_item_reports(selected_item):
var reportNode: RichTextLabel = _report_template.duplicate()
_report_list.add_child(reportNode)
reportNode.push_color(Color.DARK_TURQUOISE)
reportNode.append_text(report.to_string())
reportNode.pop()
reportNode.visible = true
func update_test_suite(event: GdUnitEvent) -> void:
var item := _find_tree_item_by_test_suite(_tree_root, event.resource_path(), event.suite_name())
if not item:
push_error("[InspectorTreeMainPanel#update_test_suite] Internal Error: Can't find test suite item '{_suite_name}' for {_resource_path} ".format(event))
return
if event.type() == GdUnitEvent.TESTSUITE_AFTER:
update_item_elapsed_time_counter(item, event.elapsed_time())
update_state(item, event)
set_state_running(item, false)
func update_test_case(event: GdUnitEvent) -> void:
var item := _find_tree_item_by_id(_tree_root, event.guid())
if not item:
#push_error("Internal Error: Can't find test id %s" % [event.guid()])
return
if event.type() == GdUnitEvent.TESTCASE_BEFORE:
set_state_running(item, true)
# force scrolling to current test case
_tree.scroll_to_item(item, true)
return
if event.type() == GdUnitEvent.TESTCASE_AFTER:
update_item_elapsed_time_counter(item, event.elapsed_time())
if event.is_success() or event.is_warning():
update_item_processed_counter(item)
update_state(item, event)
update_progress_counters(item)
func create_item(parent: TreeItem, test: GdUnitTestCase, item_name: String, type: GdUnitType) -> TreeItem:
var item := _tree.create_item(parent)
item.collapsed = true
item.set_meta(META_GDUNIT_ORIGINAL_INDEX, item.get_index())
item.set_text(0, item_name)
match type:
GdUnitType.TEST_CASE:
item.set_meta(META_TEST_CASE, test)
GdUnitType.TEST_GROUP:
# We need to create a copy of the test record meta with a new uniqe guid
item.set_meta(META_TEST_CASE, GdUnitTestCase.from(test.suite_resource_path, test.source_file, test.line_number, test.test_name))
GdUnitType.TEST_SUITE:
# We need to create a copy of the test record meta with a new uniqe guid
item.set_meta(META_TEST_CASE, GdUnitTestCase.from(test.suite_resource_path, test.source_file, test.line_number, test.suite_name))
item.set_meta(META_GDUNIT_NAME, item_name)
set_state_initial(item, type)
update_item_total_counter(item)
return item
func set_item_icon_by_state(item :TreeItem) -> void:
if item == _tree_root:
return
var state :STATE = item.get_meta(META_GDUNIT_STATE)
var is_orphan := is_item_state_orphan(item)
var resource_path := get_item_source_file(item)
item.set_icon(0, get_icon_by_file_type(resource_path, state, is_orphan))
if item.get_meta(META_GDUNIT_TYPE) == GdUnitType.FOLDER:
item.set_icon_modulate(0, Color.SKY_BLUE)
func update_item_total_counter(item: TreeItem) -> void:
if item == null:
return
var child_count := get_total_child_count(item)
if child_count > 0:
item.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, child_count)
item.set_text(0, "(0/%d) %s" % [child_count, item.get_meta(META_GDUNIT_NAME)])
update_item_total_counter(item.get_parent())
func get_total_child_count(item: TreeItem) -> int:
var total_count := 0
for child in item.get_children():
total_count += child.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX) if child.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX) else 1
return total_count
func update_item_processed_counter(item: TreeItem, add_count := 1) -> void:
if item == _tree_root:
return
var success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS) + add_count
item.set_meta(META_GDUNIT_SUCCESS_TESTS, success_count)
if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX):
item.set_text(0, "(%d/%d) %s" % [success_count, item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)])
update_item_processed_counter(item.get_parent(), add_count)
func update_progress_counters(item: TreeItem) -> void:
var index: int = _tree_root.get_meta(META_GDUNIT_PROGRESS_INDEX) + 1
var total_test: int = _tree_root.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX)
var state: STATE = item.get_meta(META_GDUNIT_STATE)
test_counters_changed.emit(index, total_test, state)
_tree_root.set_meta(META_GDUNIT_PROGRESS_INDEX, index)
func recalculate_counters(parent: TreeItem) -> void:
# Reset the counter first
if parent.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX):
parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0)
if parent.has_meta(META_GDUNIT_PROGRESS_INDEX):
parent.set_meta(META_GDUNIT_PROGRESS_INDEX, 0)
if parent.has_meta(META_GDUNIT_SUCCESS_TESTS):
parent.set_meta(META_GDUNIT_SUCCESS_TESTS, 0)
# Calculate new count based on children
var total_count := 0
var success_count := 0
var progress_index := 0
for child in parent.get_children():
if child.get_child_count() > 0:
# Recursively update child counters first
recalculate_counters(child)
# Add child's counters to parent
if child.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX):
total_count += child.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX)
if child.has_meta(META_GDUNIT_SUCCESS_TESTS):
success_count += child.get_meta(META_GDUNIT_SUCCESS_TESTS)
if child.has_meta(META_GDUNIT_PROGRESS_INDEX):
progress_index += child.get_meta(META_GDUNIT_PROGRESS_INDEX)
elif is_test_case(child):
# Count individual test cases
total_count += 1
# Count completed tests
if is_state_success(child) or is_state_warning(child) or is_state_failed(child) or is_state_error(child):
progress_index += 1
if is_state_success(child) or is_state_warning(child):
success_count += 1
# Update the counters
if total_count > 0:
parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, total_count)
parent.set_meta(META_GDUNIT_PROGRESS_INDEX, progress_index)
parent.set_meta(META_GDUNIT_SUCCESS_TESTS, success_count)
# Update the display text
parent.set_text(0, "(%d/%d) %s" % [success_count, total_count, parent.get_meta(META_GDUNIT_NAME)])
func update_item_elapsed_time_counter(item: TreeItem, time: int) -> void:
item.set_text(1, "%s" % LocalTime.elapsed(time))
item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT)
item.set_meta(META_GDUNIT_EXECUTION_TIME, time)
var parent := item.get_parent()
if parent == _tree_root:
return
var elapsed_time :int = parent.get_meta(META_GDUNIT_EXECUTION_TIME) + time
var type :GdUnitType = item.get_meta(META_GDUNIT_TYPE)
match type:
GdUnitType.TEST_CASE:
return
GdUnitType.TEST_SUITE:
update_item_elapsed_time_counter(parent, elapsed_time)
#GdUnitType.FOLDER:
# update_item_elapsed_time_counter(parent, elapsed_time)
func get_icon_by_file_type(path: String, state: STATE, orphans: bool) -> Texture2D:
if path.get_extension() == "gd":
match state:
STATE.INITIAL:
return ICON_GDSCRIPT_TEST_DEFAULT
STATE.SUCCESS:
return ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_SUCCESS
STATE.ERROR:
return ICON_GDSCRIPT_TEST_ERRORS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_ERROR
STATE.FAILED:
return ICON_GDSCRIPT_TEST_FAILED_ORPHAN if orphans else ICON_GDSCRIPT_TEST_FAILED
STATE.WARNING:
return ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_DEFAULT
STATE.FLAKY:
return ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_FLAKY
_:
return ICON_GDSCRIPT_TEST_DEFAULT
if path.get_extension() == "cs":
match state:
STATE.INITIAL:
return ICON_CSSCRIPT_TEST_DEFAULT
STATE.SUCCESS:
return ICON_CSSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_CSSCRIPT_TEST_SUCCESS
STATE.ERROR:
return ICON_CSSCRIPT_TEST_ERRORS_ORPHAN if orphans else ICON_CSSCRIPT_TEST_ERROR
STATE.FAILED:
return ICON_CSSCRIPT_TEST_FAILED_ORPHAN if orphans else ICON_CSSCRIPT_TEST_FAILED
STATE.WARNING:
return ICON_CSSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_CSSCRIPT_TEST_DEFAULT
_:
return ICON_CSSCRIPT_TEST_DEFAULT
match state:
STATE.INITIAL:
return ICON_FOLDER
STATE.ERROR:
return ICON_FOLDER
STATE.FAILED:
return ICON_FOLDER
_:
return ICON_FOLDER
func on_test_case_discover_added(test_case: GdUnitTestCase) -> void:
var test_root_folder := GdUnitSettings.test_root_folder().replace("res://", "")
var fully_qualified_name := test_case.fully_qualified_name.trim_suffix(test_case.display_name)
var parts := fully_qualified_name.split(".", false)
parts.append(test_case.display_name)
# Skip tree structure until test root folder
var index := parts.find(test_root_folder)
if index != -1:
parts = parts.slice(index+1)
match _current_tree_view_mode:
GdUnitInspectorTreeConstants.TREE_VIEW_MODE.FLAT:
create_items_tree_mode_flat(test_case, parts)
GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE:
create_items_tree_mode_tree(test_case, parts)
func create_items_tree_mode_tree(test_case: GdUnitTestCase, parts: PackedStringArray) -> void:
var parent := _tree_root
var is_suite_assigned := false
var suite_name := test_case.suite_name.split(".")[-1]
for item_name in parts:
var next := _find_tree_item(parent, item_name)
if next != null:
parent = next
continue
if not is_suite_assigned and suite_name == item_name:
next = create_item(parent, test_case, item_name, GdUnitType.TEST_SUITE)
is_suite_assigned = true
elif item_name == test_case.display_name:
next = create_item(parent, test_case, item_name, GdUnitType.TEST_CASE)
# On grouped tests (parameterized tests)
elif item_name == test_case.test_name:
next = create_item(parent, test_case, item_name, GdUnitType.TEST_GROUP)
else:
next = create_item(parent, test_case, item_name, GdUnitType.FOLDER)
parent = next
func create_items_tree_mode_flat(test_case: GdUnitTestCase, parts: PackedStringArray) -> void:
# All parts except the last two (suite name and test name/display name)
var slice_index := -2 if parts[-1] == test_case.test_name else -3
var path_parts := parts.slice(0, slice_index)
var folder_path := "/".join(path_parts)
# Find or create flat folder
var folder_item: TreeItem
if folder_path.is_empty():
folder_item = _tree_root
else:
folder_item = _find_tree_item(_tree_root, folder_path)
if folder_item == null:
folder_item = create_item(_tree_root, test_case, folder_path, GdUnitType.FOLDER)
# Find suite under the flat folder (second to last part)
var suite_item := _find_tree_item(folder_item, test_case.suite_name)
if suite_item == null:
suite_item = create_item(folder_item, test_case, test_case.suite_name, GdUnitType.TEST_SUITE)
# Add test case or group under the suite
if test_case.test_name != test_case.display_name:
# It's a parameterized test group
var group_item := _find_tree_item(suite_item, test_case.test_name)
if group_item == null:
group_item = create_item(suite_item, test_case, test_case.test_name, GdUnitType.TEST_GROUP)
create_item(group_item, test_case, test_case.display_name, GdUnitType.TEST_CASE)
else:
create_item(suite_item, test_case, test_case.display_name, GdUnitType.TEST_CASE)
func on_test_case_discover_deleted(test_case: GdUnitTestCase) -> void:
var item := _find_tree_item_by_id(_tree_root, test_case.guid)
if item != null:
var parent := item.get_parent()
parent.remove_child(item)
# update the cached counters
var item_success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS)
var item_total_test_count: int = item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0)
var total_test_count: int = parent.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0)
parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, total_test_count-item_total_test_count)
# propagate counter update to all parents
update_item_total_counter(parent)
update_item_processed_counter(parent, -item_success_count)
func on_test_case_discover_modified(test_case: GdUnitTestCase) -> void:
var item := _find_tree_item_by_id(_tree_root, test_case.guid)
if item != null:
item.set_meta(META_TEST_CASE, test_case)
item.set_text(0, test_case.display_name)
item.set_meta(META_GDUNIT_NAME, test_case.display_name)
func get_item_reports(item: TreeItem) -> Array[GdUnitReport]:
return item.get_meta(META_GDUNIT_REPORT)
func get_item_test_line_number(item: TreeItem) -> int:
if item == null or not item.has_meta(META_TEST_CASE):
return -1
var test_case: GdUnitTestCase = item.get_meta(META_TEST_CASE)
return test_case.line_number
func get_item_source_file(item: TreeItem) -> String:
if item == null or not item.has_meta(META_TEST_CASE):
return ""
var test_case: GdUnitTestCase = item.get_meta(META_TEST_CASE)
return test_case.source_file
func get_item_type(item: TreeItem) -> GdUnitType:
if item == null or not item.has_meta(META_GDUNIT_TYPE):
return GdUnitType.FOLDER
return item.get_meta(META_GDUNIT_TYPE)
func _dump_tree_as_json(dump_name: String) -> void:
var dict := _to_json(_tree_root)
var file := FileAccess.open("res://%s.json" % dump_name, FileAccess.WRITE)
file.store_string(JSON.stringify(dict, "\t"))
func _to_json(parent :TreeItem) -> Dictionary:
var item_as_dict := GdObjects.obj2dict(parent)
item_as_dict["TreeItem"]["childrens"] = parent.get_children().map(func(item: TreeItem) -> Dictionary:
return _to_json(item))
return item_as_dict
func extract_resource_path(event: GdUnitEvent) -> String:
return ProjectSettings.localize_path(event.resource_path())
func collect_test_cases(item: TreeItem, tests: Array[GdUnitTestCase] = []) -> Array[GdUnitTestCase]:
for next in item.get_children():
collect_test_cases(next, tests)
if is_test_case(item):
var test: GdUnitTestCase = item.get_meta(META_TEST_CASE)
if not tests.has(test):
tests.append(test)
return tests
func test_session_start() -> void:
_context_menu.disable_items()
reset_tree_state(_tree_root)
clear_reports()
func test_session_stop() -> void:
_context_menu.enable_items()
abort_running()
sort_tree_items(_tree_root)
# wait until the tree redraw
await get_tree().process_frame
var failure_item := _find_first_item_by_state(_tree_root, STATE.FAILED)
select_item( failure_item if failure_item else _current_selected_item)
################################################################################
# Tree signal receiver
################################################################################
func _on_tree_item_mouse_selected(mouse_position: Vector2, mouse_button_index: int) -> void:
if mouse_button_index == MOUSE_BUTTON_RIGHT:
_context_menu.position = get_screen_position() + mouse_position
_context_menu.popup()
func _on_Tree_item_selected() -> void:
_current_selected_item = _tree.get_selected()
show_failed_report(_current_selected_item)
tree_item_selected.emit(_current_selected_item)
# Opens the test suite
func _on_Tree_item_activated() -> void:
var selected_item := _tree.get_selected()
var line_number := get_item_test_line_number(selected_item)
if line_number != -1:
var script_path := ProjectSettings.localize_path(get_item_source_file(selected_item))
var resource: Script = load(script_path)
if selected_item.has_meta(META_GDUNIT_REPORT):
var reports := get_item_reports(selected_item)
var report_line_number := reports[0].line_number()
# if number -1 we use original stored line number of the test case
# in non debug mode the line number is not available
if report_line_number != -1:
line_number = report_line_number
EditorInterface.get_file_system_dock().navigate_to_path(script_path)
EditorInterface.edit_script(resource, line_number)
elif selected_item.get_meta(META_GDUNIT_TYPE) == GdUnitType.FOLDER:
# Toggle collapse if dir
selected_item.collapsed = not selected_item.collapsed
################################################################################
# external signal receiver
################################################################################
func _on_gdunit_event(event: GdUnitEvent) -> void:
match event.type():
GdUnitEvent.DISCOVER_START:
_tree_root.visible = false
_discover_hint.visible = true
init_tree()
GdUnitEvent.DISCOVER_END:
sort_tree_items(_tree_root)
select_item(_tree_root.get_first_child())
_discover_hint.visible = false
_tree_root.visible = true
#_dump_tree_as_json("tree_example_discovered")
GdUnitEvent.TESTCASE_BEFORE:
update_test_case(event)
GdUnitEvent.TESTCASE_AFTER:
update_test_case(event)
GdUnitEvent.TESTSUITE_BEFORE:
update_test_suite(event)
GdUnitEvent.TESTSUITE_AFTER:
update_test_suite(event)
GdUnitEvent.SESSION_START:
test_session_start()
GdUnitEvent.SESSION_CLOSE:
await test_session_stop()
func _on_settings_changed(property :GdUnitProperty) -> void:
match property.name():
GdUnitSettings.INSPECTOR_TREE_SORT_MODE:
sort_tree_items(_tree_root)
#_dump_tree_as_json("tree_sorted_by_%s" % GdUnitInspectorTreeConstants.SORT_MODE.keys()[property.value()])
GdUnitSettings.INSPECTOR_TREE_VIEW_MODE:
restructure_tree(_tree_root, GdUnitSettings.get_inspector_tree_view_mode())