diff --git a/.gitattributes b/.gitattributes index 4d2830a..a54720a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,7 +16,6 @@ # Files to exclude from asset-lib download. /.github export-ignore -/addons/gd-plug export-ignore /docs export-ignore /examples export-ignore /misc export-ignore @@ -32,16 +31,14 @@ /export_presets.cfg export-ignore /icon.png export-ignore /icon.png.import export-ignore -/plug.gd export-ignore /project.godot export-ignore /.github export-ignore /.import export-ignore /test export-ignore /project.godot export-ignore /default_env.tres export-ignore +/addons/gut export-ignore /.gutconfig.json export-ignore /README.md export-ignore -/addons/gd-plug export-ignore -/plug.gd export-ignore /LICENSE export-ignore /misc export-ignore diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d9b3f18..8838d76 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -200,26 +200,6 @@ jobs: name: html5-gdnative-export-templates path: misc/export_templates/godot/bin/webassembly_gdnative_${{matrix.target}}.zip - - install_plugins: - name: 'Install Plugins' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Setup Godot - uses: lihop/setup-godot@v1.0.0 - - name: Install plugins - run: godot --no-window -s plug.gd install - - name: Upload install plugins for use by other jobs - uses: actions/upload-artifact@v2 - with: - name: plugins - retention-days: 1 # Minimum. - path: | - addons - !addons/godot_xterm - - html5_export: name: 'HTML5 Export' needs: [ build_docker, build_native, export_template ] @@ -240,8 +220,6 @@ jobs: with: name: html5-gdnative-export-templates path: misc/export_templates/godot/bin - - name: Install plugins - run: godot --no-window -s plug.gd install - name: Create export directory run: mkdir -p docs/demo - name: Export html5 @@ -257,7 +235,7 @@ jobs: test: name: 'Test' - needs: [ install_plugins, build_docker, build_native ] + needs: [ build_docker, build_native ] runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -285,11 +263,6 @@ jobs: with: name: libgodot-xterm-release path: addons/godot_xterm/native/bin - - name: Install plugins - uses: actions/download-artifact@v2 - with: - name: plugins - path: ./addons - name: Run tests if: ${{ matrix.godot_version != 'v3.2-stable' }} shell: bash diff --git a/.gitignore b/.gitignore index 257852e..91e2a6a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,12 +19,6 @@ mono_crash.* .vscode .vs -# Package-manager-specific ignores -.plugged -addons/* -!addons/gd-plug -!addons/godot_xterm - # Test-specific ignores .gutconfig.json test/results.xml diff --git a/addons/gd-plug/plug.gd b/addons/gd-plug/plug.gd deleted file mode 100644 index 3103870..0000000 --- a/addons/gd-plug/plug.gd +++ /dev/null @@ -1,1124 +0,0 @@ -tool -extends SceneTree - -signal updated(plugin) - -const VERSION = "0.1.1" -const DEFAULT_PLUGIN_URL = "https://git::@github.com/%s.git" -const DEFAULT_PLUG_DIR = "res://.plugged" -const DEFAULT_CONFIG_PATH = DEFAULT_PLUG_DIR + "/index.cfg" -const DEFAULT_USER_PLUG_SCRIPT_PATH = "res://plug.gd" -const DEFAULT_BASE_PLUG_SCRIPT_PATH = "res://addons/gd-plug/plug.gd" - -const ENV_PRODUCTION = "production" -const ENV_TEST = "test" -const ENV_FORCE = "force" -const ENV_KEEP_IMPORT_FILE = "keep_import_file" -const ENV_KEEP_IMPORT_RESOURCE_FILE = "keep_import_resource_file" - -const MSG_PLUG_START_ASSERTION = "_plug_start() must be called first" - -var project_dir = Directory.new() -var installation_config = ConfigFile.new() -var logger = _Logger.new() - -var _installed_plugins -var _plugged_plugins = {} - -var _threads = [] -var _mutex = Mutex.new() -var _start_time = 0 -var threadpool = _ThreadPool.new(logger) - - -func _init(): - threadpool.connect("all_thread_finished", self, "request_quit") - - -func _initialize(): - var args = OS.get_cmdline_args() - # Trim unwanted args passed to godot executable - for arg in Array(args): - args.remove(0) - if "plug.gd" in arg: - break - - for arg in args: - # NOTE: "--key" or "-key" will always be consumed by godot executable, see https://github.com/godotengine/godot/issues/8721 - var key = arg.to_lower() - match key: - "detail": - logger.log_format = _Logger.DEFAULT_LOG_FORMAT_DETAIL - "debug", "d": - logger.log_level = _Logger.LogLevel.DEBUG - "quiet", "q", "silent": - logger.log_level = _Logger.LogLevel.NONE - "production": - OS.set_environment(ENV_PRODUCTION, "true") - "test": - OS.set_environment(ENV_TEST, "true") - "force": - OS.set_environment(ENV_FORCE, "true") - "keep-import-file": - OS.set_environment(ENV_KEEP_IMPORT_FILE, "true") - "keep-import-resource-file": - OS.set_environment(ENV_KEEP_IMPORT_RESOURCE_FILE, "true") - - logger.debug("cmdline_args: %s" % args) - _start_time = OS.get_system_time_msecs() - _plug_start() - if args.size() > 0: - _plugging() - match args[0]: - "init": - _plug_init() - "install", "update": - _plug_install() - "uninstall": - _plug_uninstall() - "clean": - _plug_clean() - "upgrade": - _plug_upgrade() - "status": - _plug_status() - "version": - logger.info(VERSION) - _: - logger.error("Unknown command %s" % args[0]) - # NOTE: Do no put anything after this line except request_quit(), as _plug_*() may call request_quit() - request_quit() - - -func _idle(delta): - threadpool.process(delta) - - -func _finalize(): - _plug_end() - logger.info("Finished, elapsed %.3fs" % ((OS.get_system_time_msecs() - _start_time) / 1000.0)) - - -func _on_updated(plugin): - pass - - -func _plugging(): - pass - - -func request_quit(exit_code = -1): - if threadpool.is_all_thread_finished() and threadpool.is_all_task_finished(): - quit(exit_code) - return true - logger.debug("Request quit declined, threadpool is still running") - return false - - -# Index installed plugins, or create directory "plugged" if not exists -func _plug_start(): - logger.debug("Plug start") - if not project_dir.dir_exists(DEFAULT_PLUG_DIR): - if project_dir.make_dir(ProjectSettings.globalize_path(DEFAULT_PLUG_DIR)) == OK: - logger.debug("Make dir %s for plugin installation") - if installation_config.load(DEFAULT_CONFIG_PATH) == OK: - logger.debug("Installation config loaded") - else: - logger.debug("Installation config not found") - _installed_plugins = installation_config.get_value("plugin", "installed", {}) - - -# Install plugin or uninstall plugin if unlisted -func _plug_end(): - assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION) - var test = !!OS.get_environment(ENV_TEST) - if not test: - installation_config.set_value("plugin", "installed", _installed_plugins) - if installation_config.save(DEFAULT_CONFIG_PATH) == OK: - logger.debug("Plugged config saved") - else: - logger.error("Failed to save plugged config") - else: - logger.warn("Skipped saving of plugged config in test mode") - _installed_plugins = null - - -func _plug_init(): - assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION) - logger.info("Init gd-plug...") - var file = File.new() - if file.file_exists(DEFAULT_USER_PLUG_SCRIPT_PATH): - logger.warn("%s already exists!" % DEFAULT_USER_PLUG_SCRIPT_PATH) - else: - file.open(DEFAULT_USER_PLUG_SCRIPT_PATH, File.WRITE) - file.store_string(INIT_PLUG_SCRIPT) - file.close() - logger.info("Created %s" % DEFAULT_USER_PLUG_SCRIPT_PATH) - - -func _plug_install(): - assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION) - logger.info("Installing...") - for plugin in _plugged_plugins.values(): - var installed = plugin.name in _installed_plugins - if installed: - var installed_plugin = get_installed_plugin(plugin.name) - if (installed_plugin.dev or plugin.dev) and OS.get_environment(ENV_PRODUCTION): - logger.info("Remove dev plugin for production: %s" % plugin.name) - threadpool.enqueue_task(self, "uninstall_plugin", installed_plugin) - else: - threadpool.enqueue_task(self, "update_plugin", plugin) - else: - threadpool.enqueue_task(self, "install_plugin", plugin) - - var removed_plugins = [] - for plugin in _installed_plugins.values(): - var removed = not (plugin.name in _plugged_plugins) - if removed: - removed_plugins.append(plugin) - if removed_plugins: - threadpool.disconnect("all_thread_finished", self, "request_quit") - if not threadpool.is_all_thread_finished(): - yield(threadpool, "all_thread_finished") - logger.debug("All installation finished! Ready to uninstall removed plugins...") - threadpool.connect("all_thread_finished", self, "request_quit") - for plugin in removed_plugins: - threadpool.enqueue_task(self, "uninstall_plugin", plugin, Thread.PRIORITY_LOW) - - -func _plug_uninstall(): - assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION) - logger.info("Uninstalling...") - for plugin in _installed_plugins.values(): - var installed_plugin = get_installed_plugin(plugin.name) - threadpool.enqueue_task(self, "uninstall_plugin", installed_plugin, Thread.PRIORITY_LOW) - - -func _plug_clean(): - assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION) - logger.info("Cleaning...") - var plugged_dir = Directory.new() - plugged_dir.open(DEFAULT_PLUG_DIR) - plugged_dir.list_dir_begin(true, true) - var file = plugged_dir.get_next() - while not file.empty(): - if plugged_dir.current_is_dir(): - if not (file in _installed_plugins): - logger.info("Remove %s" % file) - threadpool.enqueue_task( - self, "directory_delete_recursively", plugged_dir.get_current_dir() + "/" + file - ) - file = plugged_dir.get_next() - plugged_dir.list_dir_end() - - -func _plug_upgrade(): - assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION) - logger.info("Upgrading gd-plug...") - plug("imjp94/gd-plug") - var gd_plug = _plugged_plugins["gd-plug"] - OS.set_environment(ENV_FORCE, "true") # Required to overwrite res://addons/gd-plug/plug.gd - threadpool.enqueue_task(self, "install_plugin", gd_plug) - threadpool.disconnect("all_thread_finished", self, "request_quit") - if not threadpool.is_all_thread_finished(): - yield(threadpool, "all_thread_finished") - logger.debug("All installation finished! Ready to uninstall removed plugins...") - threadpool.connect("all_thread_finished", self, "request_quit") - threadpool.enqueue_task(self, "directory_delete_recursively", gd_plug.plug_dir) - - -func _plug_status(): - assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION) - logger.info( - ( - "Installed %d plugin%s" - % [_installed_plugins.size(), "s" if _installed_plugins.size() > 1 else ""] - ) - ) - var new_plugins = _plugged_plugins.duplicate() - var has_checking_plugin = false - var removed_plugins = [] - for plugin in _installed_plugins.values(): - logger.info("- {name} - {url}".format(plugin)) - new_plugins.erase(plugin.name) - var removed = not (plugin.name in _plugged_plugins) - if removed: - removed_plugins.append(plugin) - else: - threadpool.enqueue_task(self, "check_plugin", _plugged_plugins[plugin.name]) - has_checking_plugin = true - if has_checking_plugin: - logger.info("\n", true) - threadpool.disconnect("all_thread_finished", self, "request_quit") - yield(threadpool, "all_thread_finished") - threadpool.connect("all_thread_finished", self, "request_quit") - logger.debug("Finished checking plugins, ready to proceed") - if new_plugins: - logger.info( - "\nPlugged %d plugin%s" % [new_plugins.size(), "s" if new_plugins.size() > 1 else ""] - ) - for plugin in new_plugins.values(): - var is_new = not (plugin.name in _installed_plugins) - if is_new: - logger.info("- {name} - {url}".format(plugin)) - if removed_plugins: - logger.info( - ( - "\nUnplugged %d plugin%s" - % [removed_plugins.size(), "s" if removed_plugins.size() > 1 else ""] - ) - ) - for plugin in removed_plugins: - logger.info("- %s removed" % plugin.name) - var plug_directory = Directory.new() - var orphan_dirs = [] - if plug_directory.open(DEFAULT_PLUG_DIR) == OK: - plug_directory.list_dir_begin(true, true) - var file = plug_directory.get_next() - while not file.empty(): - if plug_directory.current_is_dir(): - if not (file in _installed_plugins): - orphan_dirs.append(file) - file = plug_directory.get_next() - plug_directory.list_dir_end() - if orphan_dirs: - logger.info( - ( - '\nOrphan directory, %d found in %s, execute "clean" command to remove' - % [orphan_dirs.size(), DEFAULT_PLUG_DIR] - ) - ) - for dir in orphan_dirs: - logger.info("- %s" % dir) - - if has_checking_plugin: - request_quit() - - -# Index & validate plugin -func plug(repo, args = {}): - assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION) - repo = repo.strip_edges() - var plugin_name = get_plugin_name_from_repo(repo) - if plugin_name in _plugged_plugins: - logger.info("Plugin already plugged: %s" % plugin_name) - return - var plugin = {} - plugin.name = plugin_name - plugin.url = "" - if ":" in repo: - plugin.url = repo - elif repo.find("/") == repo.rfind("/"): - plugin.url = DEFAULT_PLUGIN_URL % repo - else: - logger.error("Invalid repo: %s" % repo) - plugin.plug_dir = DEFAULT_PLUG_DIR + "/" + plugin.name - - var is_valid = true - plugin.include = args.get("include", []) - is_valid = is_valid and validate_var_type(plugin, "include", TYPE_ARRAY, "Array") - plugin.exclude = args.get("exclude", []) - is_valid = is_valid and validate_var_type(plugin, "exclude", TYPE_ARRAY, "Array") - plugin.branch = args.get("branch", "") - is_valid = is_valid and validate_var_type(plugin, "branch", TYPE_STRING, "String") - plugin.tag = args.get("tag", "") - is_valid = is_valid and validate_var_type(plugin, "tag", TYPE_STRING, "String") - plugin.commit = args.get("commit", "") - is_valid = is_valid and validate_var_type(plugin, "commit", TYPE_STRING, "String") - if not plugin.commit.empty(): - var is_valid_commit = plugin.commit.length() == 40 - if not is_valid_commit: - logger.error( - "Expected full length 40 digits commit-hash string, given %s" % plugin.commit - ) - is_valid = is_valid and is_valid_commit - plugin.dev = args.get("dev", false) - is_valid = is_valid and validate_var_type(plugin, "dev", TYPE_BOOL, "Boolean") - plugin.on_updated = args.get("on_updated", "") - is_valid = is_valid and validate_var_type(plugin, "on_updated", TYPE_STRING, "String") - plugin.install_root = args.get("install_root", "") - is_valid = is_valid and validate_var_type(plugin, "install_root", TYPE_STRING, "String") - - if is_valid: - _plugged_plugins[plugin.name] = plugin - logger.debug("Plug: %s" % plugin) - else: - logger.error("Failed to plug %s, validation error" % plugin.name) - - -func install_plugin(plugin): - var test = !!OS.get_environment(ENV_TEST) - var can_install = not OS.get_environment(ENV_PRODUCTION) if plugin.dev else true - if can_install: - logger.info("Installing plugin %s..." % plugin.name) - var result = is_plugin_downloaded(plugin) - if result != OK: - result = downlaod(plugin) - else: - logger.info("Plugin already downloaded") - - if result == OK: - install(plugin) - else: - logger.error("Failed to install plugin %s with error code %d" % [plugin.name, result]) - - -func uninstall_plugin(plugin): - var test = !!OS.get_environment(ENV_TEST) - logger.info("Uninstalling plugin %s..." % plugin.name) - uninstall(plugin) - directory_delete_recursively(plugin.plug_dir, {"exclude": [DEFAULT_CONFIG_PATH], "test": test}) - - -func update_plugin(plugin, checking = false): - if not (plugin.name in _installed_plugins): - logger.info("%s new plugin" % plugin.name) - return true - - var git = _GitExecutable.new(ProjectSettings.globalize_path(plugin.plug_dir), logger) - var installed_plugin = get_installed_plugin(plugin.name) - var changes = compare_plugins(plugin, installed_plugin) - var should_clone = false - var should_pull = false - var should_reinstall = false - - if plugin.tag or plugin.commit: - for rev in ["tag", "commit"]: - var freeze_at = plugin[rev] - if freeze_at: - logger.info('%s frozen at %s "%s"' % [plugin.name, rev, freeze_at]) - break - else: - var ahead_behind = [] - if git.fetch("origin " + plugin.branch if plugin.branch else "origin").exit == OK: - ahead_behind = git.get_commit_comparison( - "HEAD", "origin/" + plugin.branch if plugin.branch else "origin" - ) - var is_commit_behind = !!ahead_behind[1] if ahead_behind.size() == 2 else false - if is_commit_behind: - logger.info("%s %d commits behind, update required" % [plugin.name, ahead_behind[1]]) - should_pull = true - else: - logger.info("%s up to date" % plugin.name) - - if changes: - logger.info("%s changed %s" % [plugin.name, changes]) - should_reinstall = true - if "url" in changes or "branch" in changes or "tag" in changes or "commit" in changes: - logger.info("%s repository setting changed, update required" % plugin.name) - should_clone = true - - if not checking: - if should_clone: - logger.info("%s cloning from %s..." % [plugin.name, plugin.url]) - var test = !!OS.get_environment(ENV_TEST) - uninstall(get_installed_plugin(plugin.name)) - directory_delete_recursively( - plugin.plug_dir, {"exclude": [DEFAULT_CONFIG_PATH], "test": test} - ) - if downlaod(plugin) == OK: - install(plugin) - elif should_pull: - logger.info("%s pulling updates from %s..." % [plugin.name, plugin.url]) - uninstall(get_installed_plugin(plugin.name)) - if git.pull().exit == OK: - install(plugin) - elif should_reinstall: - logger.info("%s reinstalling..." % plugin.name) - uninstall(get_installed_plugin(plugin.name)) - install(plugin) - - -func check_plugin(plugin): - update_plugin(plugin, true) - - -func downlaod(plugin): - logger.info("Downloading %s from %s..." % [plugin.name, plugin.url]) - var test = !!OS.get_environment(ENV_TEST) - var global_dest_dir = ProjectSettings.globalize_path(plugin.plug_dir) - if project_dir.dir_exists(plugin.plug_dir): - directory_delete_recursively(plugin.plug_dir) - project_dir.make_dir(plugin.plug_dir) - var result = _GitExecutable.new(global_dest_dir, logger).clone( - plugin.url, - global_dest_dir, - {"branch": plugin.branch, "tag": plugin.tag, "commit": plugin.commit} - ) - if result.exit == OK: - logger.info("Successfully download %s" % [plugin.name]) - else: - logger.info("Failed to download %s" % plugin.name) - # Make sure plug_dir is clean when failed - directory_delete_recursively( - plugin.plug_dir, {"exclude": [DEFAULT_CONFIG_PATH], "test": test} - ) - project_dir.remove(plugin.plug_dir) # Remove empty directory - return result.exit - - -func install(plugin): - var include = plugin.get("include", []) - if include.empty(): # Auto include "addons/" folder if not explicitly specified - include = ["addons/"] - if not OS.get_environment(ENV_FORCE) and not OS.get_environment(ENV_TEST): - var is_exists = false - var dest_files = directory_copy_recursively( - plugin.plug_dir, - "res://" + plugin.install_root, - {"include": include, "exclude": plugin.exclude, "test": true, "silent_test": true} - ) - for dest_file in dest_files: - if project_dir.file_exists(dest_file): - logger.warn("%s attempting to overwrite file %s" % [plugin.name, dest_file]) - is_exists = true - if is_exists: - logger.warn( - ( - 'Installation of %s terminated to avoid overwriting user files, you may disable safe mode with command "force"' - % plugin.name - ) - ) - return ERR_ALREADY_EXISTS - - logger.info("Installing files for %s..." % plugin.name) - var test = !!OS.get_environment(ENV_TEST) - var dest_files = directory_copy_recursively( - plugin.plug_dir, - "res://" + plugin.install_root, - {"include": include, "exclude": plugin.exclude, "test": test} - ) - plugin.dest_files = dest_files - logger.info( - ( - "Installed %d file%s for %s" - % [dest_files.size(), "s" if dest_files.size() > 1 else "", plugin.name] - ) - ) - if plugin.name != "gd-plug": - set_installed_plugin(plugin) - if plugin.on_updated: - if has_method(plugin.on_updated): - logger.info("Execute post-update function for %s: %s" % [plugin.name, plugin.do]) - _on_updated(plugin) - call(plugin.on_updated, plugin.duplicate()) - emit_signal("updated", plugin) - return OK - - -func uninstall(plugin): - var test = !!OS.get_environment(ENV_TEST) - var keep_import_file = !!OS.get_environment(ENV_KEEP_IMPORT_FILE) - var keep_import_resource_file = !!OS.get_environment(ENV_KEEP_IMPORT_RESOURCE_FILE) - var dest_files = plugin.get("dest_files", []) - logger.info( - ( - "Uninstalling %d file%s for %s..." - % [dest_files.size(), "s" if dest_files.size() > 1 else "", plugin.name] - ) - ) - directory_remove_batch( - dest_files, - { - "test": test, - "keep_import_file": keep_import_file, - "keep_import_resource_file": keep_import_resource_file - } - ) - logger.info( - ( - "Uninstalled %d file%s for %s" - % [dest_files.size(), "s" if dest_files.size() > 1 else "", plugin.name] - ) - ) - remove_installed_plugin(plugin.name) - - -func is_plugin_downloaded(plugin): - if not project_dir.dir_exists(plugin.plug_dir + "/.git"): - return - - var git = _GitExecutable.new(ProjectSettings.globalize_path(plugin.plug_dir), logger) - return git.is_up_to_date(plugin) - - -# Get installed plugin, thread safe -func get_installed_plugin(plugin_name): - assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION) - _mutex.lock() - var installed_plugin = _installed_plugins[plugin_name] - _mutex.unlock() - return installed_plugin - - -# Set installed plugin, thread safe -func set_installed_plugin(plugin): - assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION) - _mutex.lock() - _installed_plugins[plugin.name] = plugin - _mutex.unlock() - - -# Remove installed plugin, thread safe -func remove_installed_plugin(plugin_name): - assert(_installed_plugins != null, MSG_PLUG_START_ASSERTION) - _mutex.lock() - var result = _installed_plugins.erase(plugin_name) - _mutex.unlock() - return result - - -func directory_copy_recursively(from, to, args = {}): - var include = args.get("include", []) - var exclude = args.get("exclude", []) - var test = args.get("test", false) - var silent_test = args.get("silent_test", false) - var dir = Directory.new() - var dest_files = [] - if dir.open(from) == OK: - dir.list_dir_begin(true, true) - var file_name = dir.get_next() - while not file_name.empty(): - var source = ( - dir.get_current_dir() - + ("/" if dir.get_current_dir() != "res://" else "") - + file_name - ) - var dest = to + ("/" if to != "res://" else "") + file_name - - if dir.current_is_dir(): - dest_files += directory_copy_recursively(source, dest, args) - else: - for include_key in include: - if include_key in source: - var is_excluded = false - for exclude_key in exclude: - if exclude_key in source: - is_excluded = true - break - if not is_excluded: - if test: - if not silent_test: - logger.warn("[TEST] Writing to %s" % dest) - else: - dir.make_dir_recursive(to) - if dir.copy(source, dest) == OK: - logger.debug("Copy from %s to %s" % [source, dest]) - dest_files.append(dest) - break - file_name = dir.get_next() - dir.list_dir_end() - else: - logger.error("Failed to access path: %s" % from) - - return dest_files - - -func directory_delete_recursively(dir_path, args = {}): - var remove_empty_directory = args.get("remove_empty_directory", true) - var exclude = args.get("exclude", []) - var test = args.get("test", false) - var silent_test = args.get("silent_test", false) - var dir = Directory.new() - if dir.open(dir_path) == OK: - dir.list_dir_begin(true, false) - var file_name = dir.get_next() - while not file_name.empty(): - var source = ( - dir.get_current_dir() - + ("/" if dir.get_current_dir() != "res://" else "") - + file_name - ) - - if dir.current_is_dir(): - var sub_dir = directory_delete_recursively(source, args) - if remove_empty_directory: - if test: - if not silent_test: - logger.warn( - "[TEST] Remove empty directory: %s" % sub_dir.get_current_dir() - ) - else: - if source.get_file() == ".git": - # Hacks to remove .git, as git pack files stop it from being removed - # See https://stackoverflow.com/questions/1213430/how-to-fully-delete-a-git-repository-created-with-init - if ( - OS.execute("rm", ["-rf", ProjectSettings.globalize_path(source)]) - == OK - ): - logger.debug( - "Remove empty directory: %s" % sub_dir.get_current_dir() - ) - else: - if dir.remove(sub_dir.get_current_dir()) == OK: - logger.debug( - "Remove empty directory: %s" % sub_dir.get_current_dir() - ) - else: - var excluded = false - for exclude_key in exclude: - if source in exclude_key: - excluded = true - break - if not excluded: - if test: - if not silent_test: - logger.warn("[TEST] Remove file: %s" % source) - else: - if dir.remove(file_name) == OK: - logger.debug("Remove file: %s" % source) - file_name = dir.get_next() - dir.list_dir_end() - else: - logger.error("Failed to access path: %s" % dir_path) - - if remove_empty_directory: - dir.remove(dir.get_current_dir()) - - return dir - - -func directory_remove_batch(files, args = {}): - var remove_empty_directory = args.get("remove_empty_directory", true) - var keep_import_file = args.get("keep_import_file", false) - var keep_import_resource_file = args.get("keep_import_resource_file", false) - var test = args.get("test", false) - var silent_test = args.get("silent_test", false) - var dirs = {} - for file in files: - var file_dir = file.get_base_dir() - var file_name = file.get_file() - var dir = dirs.get(file_dir) - - if not dir: - dir = Directory.new() - dir.open(file_dir) - dirs[file_dir] = dir - - if file.ends_with(".import"): - if not keep_import_file: - _remove_import_file(dir, file, keep_import_resource_file, test, silent_test) - else: - if test: - if not silent_test: - logger.warn("[TEST] Remove file: " + file) - else: - if dir.remove(file_name) == OK: - logger.debug("Remove file: " + file) - if not keep_import_file: - _remove_import_file( - dir, file + ".import", keep_import_resource_file, test, silent_test - ) - - for dir in dirs.values(): - var slash_count = dir.get_current_dir().count("/") - 2 # Deduct 2 slash from "res://" - if test: - if not silent_test: - logger.warn("[TEST] Remove empty directory: %s" % dir.get_current_dir()) - else: - if dir.remove(dir.get_current_dir()) == OK: - logger.debug("Remove empty directory: %s" % dir.get_current_dir()) - # Dumb method to clean empty ancestor directories - logger.debug("Removing emoty ancestor directory for %s..." % dir.get_current_dir()) - var current_dir = dir.get_current_dir() - for i in slash_count: - current_dir = current_dir.get_base_dir() - var d = Directory.new() - if d.open(current_dir) == OK: - if test: - if not silent_test: - logger.warn( - "[TEST] Remove empty ancestor directory: %s" % d.get_current_dir() - ) - else: - if d.remove(d.get_current_dir()) == OK: - logger.debug("Remove empty ancestor directory: %s" % d.get_current_dir()) - - -func _remove_import_file( - dir, file, keep_import_resource_file = false, test = false, silent_test = false -): - if not dir.file_exists(file): - return - - if not keep_import_resource_file: - var import_config = ConfigFile.new() - if import_config.load(file) == OK: - var metadata = import_config.get_value("remap", "metadata", {}) - var imported_formats = metadata.get("imported_formats", []) - if imported_formats: - for format in imported_formats: - _remove_import_resource_file(dir, import_config, "." + format, test) - else: - _remove_import_resource_file(dir, import_config, "", test) - if test: - if not silent_test: - logger.warn("[TEST] Remove import file: " + file) - else: - if dir.remove(file) == OK: - logger.debug("Remove import file: " + file) - else: - # TODO: Sometimes Directory.remove() unable to remove random .import file and return error code 1(Generic Error) - # Maybe enforce the removal from shell? - logger.warn("Failed to remove import file: " + file) - - -func _remove_import_resource_file(dir, import_config, import_format = "", test = false): - var import_resource_file = import_config.get_value("remap", "path" + import_format, "") - var checksum_file = ( - import_resource_file.trim_suffix("." + import_resource_file.get_extension()) + ".md5" - if import_resource_file - else "" - ) - if import_resource_file: - if dir.file_exists(import_resource_file): - if test: - logger.info("[IMPORT] Remove import resource file: " + import_resource_file) - else: - if dir.remove(import_resource_file) == OK: - logger.debug("Remove import resource file: " + import_resource_file) - if checksum_file: - checksum_file = checksum_file.replace(import_format, "") - if dir.file_exists(checksum_file): - if test: - logger.info("[IMPORT] Remove import checksum file: " + checksum_file) - else: - if dir.remove(checksum_file) == OK: - logger.debug("Remove import checksum file: " + checksum_file) - - -func compare_plugins(p1, p2): - var changed_keys = [] - for key in p1.keys(): - var v1 = p1[key] - var v2 = p2[key] - if v1 != v2: - changed_keys.append(key) - return changed_keys - - -func get_plugin_name_from_repo(repo): - repo = repo.replace(".git", "").trim_suffix("/") - return repo.get_file() - - -func validate_var_type(obj, var_name, type, type_string): - var value = obj.get(var_name) - var is_valid = typeof(value) == type - if not is_valid: - logger.error('Expected variable "%s" to be %s, given %s' % [var_name, type_string, value]) - return is_valid - - -const INIT_PLUG_SCRIPT = """extends "res://addons/gd-plug/plug.gd" - -func _plugging(): - # Declare plugins with plug(repo, args) - # For example, clone from github repo("user/repo_name") - # plug("imjp94/gd-YAFSM") # By default, gd-plug will only install anything from "addons/" directory - # Or you can explicitly specify which file/directory to include - # plug("imjp94/gd-YAFSM", {"include": ["addons/"]}) # By default, gd-plug will only install anything from "addons/" directory - pass -""" - - -class _GitExecutable: - extends Reference - var cwd = "" - var logger - - func _init(p_cwd, p_logger): - cwd = p_cwd - logger = p_logger - - func _execute(command, blocking = true, output = [], read_stderr = false): - var cmd = "cd %s && %s" % [cwd, command] - # NOTE: OS.execute() seems to ignore read_stderr - var exit = FAILED - match OS.get_name(): - "Windows": - cmd = cmd if read_stderr else "%s 2> nul" % cmd - logger.debug('Execute "%s"' % cmd) - exit = OS.execute("cmd", ["/C", cmd], blocking, output, read_stderr) - "X11", "OSX", "Server": - cmd if read_stderr else "%s 2>/dev/null" % cmd - logger.debug('Execute "%s"' % cmd) - exit = OS.execute("bash", ["-c", cmd], blocking, output, read_stderr) - var unhandled_os: - logger.error("Unexpected OS: %s" % unhandled_os) - logger.debug("Execution ended(code:%d): %s" % [exit, output]) - return exit - - func init(): - logger.debug("Initializing git at %s..." % cwd) - var output = [] - var exit = _execute("git init", true, output) - logger.debug("Successfully init" if exit == OK else "Failed to init") - return {"exit": exit, "output": output} - - func clone(src, dest, args = {}): - logger.debug("Cloning from %s to %s..." % [src, dest]) - var output = [] - var branch = args.get("branch", "") - var tag = args.get("tag", "") - var commit = args.get("commit", "") - var command = "git clone --depth=1 --progress %s %s" % [src, dest] - if branch or tag: - command = ( - "git clone --depth=1 --single-branch --branch %s %s %s" - % [branch if branch else tag, src, dest] - ) - elif commit: - return clone_commit(src, dest, commit) - var exit = _execute(command, true, output) - logger.debug( - "Successfully cloned from %s" % src if exit == OK else "Failed to clone from %s" % src - ) - return {"exit": exit, "output": output} - - func clone_commit(src, dest, commit): - var output = [] - if commit.length() < 40: - logger.error( - ( - "Expected full length 40 digits commit-hash to clone specific commit, given {%s}" - % commit - ) - ) - return {"exit": FAILED, "output": output} - - logger.debug("Cloning from %s to %s @ %s..." % [src, dest, commit]) - var result = init() - if result.exit == OK: - result = remote_add("origin", src) - if result.exit == OK: - result = fetch("%s %s" % ["origin", commit]) - if result.exit == OK: - result = reset("--hard", "FETCH_HEAD") - return result - - func fetch(rm = "--all"): - logger.debug("Fetching %s..." % rm.replace("--", "")) - var output = [] - var exit = _execute("git fetch %s" % rm, true, output) - logger.debug("Successfully fetched" if exit == OK else "Failed to fetch") - return {"exit": exit, "output": output} - - func pull(): - logger.debug("Pulling...") - var output = [] - var exit = _execute("git pull --rebase", true, output) - logger.debug("Successfully pulled" if exit == OK else "Failed to pull") - return {"exit": exit, "output": output} - - func remote_add(name, src): - logger.debug("Adding remote %s@%s..." % [name, src]) - var output = [] - var exit = _execute("git remote add %s %s" % [name, src], true, output) - logger.debug("Successfully added remote" if exit == OK else "Failed to add remote") - return {"exit": exit, "output": output} - - func reset(mode, to): - logger.debug("Resetting %s %s..." % [mode, to]) - var output = [] - var exit = _execute("git reset %s %s" % [mode, to], true, output) - logger.debug("Successfully reset" if exit == OK else "Failed to reset") - return {"exit": exit, "output": output} - - func get_commit_comparison(branch_a, branch_b): - var output = [] - var exit = _execute( - "git rev-list --count --left-right %s...%s" % [branch_a, branch_b], true, output - ) - var raw_ahead_behind = output[0].split("\t") - var ahead_behind = [] - for msg in raw_ahead_behind: - ahead_behind.append(int(msg)) - return ahead_behind if exit == OK else [] - - func get_current_branch(): - var output = [] - var exit = _execute("git rev-parse --abbrev-ref HEAD", true, output) - return output[0] if exit == OK else "" - - func get_current_tag(): - var output = [] - var exit = _execute("git describe --tags --exact-match", true, output) - return output[0] if exit == OK else "" - - func get_current_commit(): - var output = [] - var exit = _execute("git rev-parse --short HEAD", true, output) - return output[0] if exit == OK else "" - - func is_detached_head(): - var output = [] - var exit = _execute("git rev-parse --short HEAD", true, output) - return (!!output[0]) if exit == OK else true - - func is_up_to_date(args = {}): - if fetch().exit == OK: - var branch = args.get("branch", "") - var tag = args.get("tag", "") - var commit = args.get("commit", "") - - if branch: - if branch == get_current_branch(): - return FAILED if is_detached_head() else OK - elif tag: - if tag == get_current_tag(): - return OK - elif commit: - if commit == get_current_commit(): - return OK - - var ahead_behind = get_commit_comparison("HEAD", "origin") - var is_commit_behind = !!ahead_behind[1] if ahead_behind.size() == 2 else false - return FAILED if is_commit_behind else OK - return FAILED - - -class _ThreadPool: - extends Reference - signal all_thread_finished - - var _threads = [] - var _finished_threads = [] - var _mutex = Mutex.new() - var _tasks = [] - var logger - - func _init(p_logger): - logger = p_logger - _threads.resize(OS.get_processor_count()) - - func _execute_task(task): - var thread = _get_thread() - var can_execute = thread - if can_execute: - task.thread = weakref(thread) - thread.start(self, "_execute", task, task.priority) - logger.debug("Execute task %s.%s() " % [task.instance, task.method]) - return can_execute - - func _execute(args): - args.instance.call(args.method, args.userdata) - _mutex.lock() - var thread = args.thread.get_ref() - _threads[_threads.find(thread)] = null - _finished_threads.append(thread) - var all_finished = is_all_thread_finished() - _mutex.unlock() - - logger.debug("Execution finished %s.%s() " % [args.instance, args.method]) - if all_finished: - logger.debug("All thread finished") - emit_signal("all_thread_finished") - - func _flush_tasks(): - if not _tasks: - return - - var executed = true - while executed: - var task = _tasks.pop_front() - if task != null: - executed = _execute_task(task) - if not executed: - _tasks.push_front(task) - else: - executed = false - - func _flush_threads(): - for i in _finished_threads.size(): - var thread = _finished_threads.pop_front() - thread.wait_to_finish() - - func enqueue_task(instance, method, userdata = null, priority = 1): - enqueue( - {"instance": instance, "method": method, "userdata": userdata, "priority": priority} - ) - - func enqueue(task): - var can_execute = _execute_task(task) - if not can_execute: - _tasks.append(task) - - func process(delta): - _flush_tasks() - _flush_threads() - - func _get_thread(): - var thread - for i in OS.get_processor_count(): - var t = _threads[i] - if t: - if not t.is_active(): - thread = t - break - else: - thread = Thread.new() - _threads[i] = thread - break - return thread - - func is_all_thread_finished(): - for i in _threads.size(): - if _threads[i]: - return false - return true - - func is_all_task_finished(): - for i in _tasks.size(): - if _tasks[i]: - return false - return true - - -class _Logger: - extends Reference - enum LogLevel { ALL, DEBUG, INFO, WARN, ERROR, NONE } - const DEFAULT_LOG_FORMAT_DETAIL = "[{time}] [{level}] {msg}" - const DEFAULT_LOG_FORMAT_NORMAL = "{msg}" - - var log_level = LogLevel.INFO - var log_format = DEFAULT_LOG_FORMAT_NORMAL - var log_time_format = "{year}/{month}/{day} {hour}:{minute}:{second}" - - func debug(msg, raw = false): - _log(LogLevel.DEBUG, msg, raw) - - func info(msg, raw = false): - _log(LogLevel.INFO, msg, raw) - - func warn(msg, raw = false): - _log(LogLevel.WARN, msg, raw) - - func error(msg, raw = false): - _log(LogLevel.ERROR, msg, raw) - - func _log(level, msg, raw = false): - if log_level <= level: - if raw: - printraw(format_log(level, msg)) - else: - print(format_log(level, msg)) - - func format_log(level, msg): - return log_format.format( - { - "time": log_time_format.format(get_formatted_datatime()), - "level": LogLevel.keys()[level], - "msg": msg - } - ) - - func get_formatted_datatime(): - var datetime = OS.get_datetime() - datetime.year = "%04d" % datetime.year - datetime.month = "%02d" % datetime.month - datetime.day = "%02d" % datetime.day - datetime.hour = "%02d" % datetime.hour - datetime.minute = "%02d" % datetime.minute - datetime.second = "%02d" % datetime.second - return datetime diff --git a/addons/gut/GutScene.gd b/addons/gut/GutScene.gd new file mode 100644 index 0000000..e8ab94c --- /dev/null +++ b/addons/gut/GutScene.gd @@ -0,0 +1,556 @@ +extends Panel + +onready var _script_list = $ScriptsList +onready var _nav_container = $VBox/BottomPanel/VBox/HBox/Navigation +onready var _nav = { + container = _nav_container, + prev = _nav_container.get_node("VBox/HBox/Previous"), + next = _nav_container.get_node("VBox/HBox/Next"), + run = _nav_container.get_node("VBox/HBox/Run"), + current_script = _nav_container.get_node("VBox/CurrentScript"), + run_single = _nav_container.get_node("VBox/HBox/RunSingleScript") +} + +onready var _progress_container = $VBox/BottomPanel/VBox/HBox/Progress +onready var _progress = { + script = _progress_container.get_node("ScriptProgress"), + script_xy = _progress_container.get_node("ScriptProgress/xy"), + test = _progress_container.get_node("TestProgress"), + test_xy = _progress_container.get_node("TestProgress/xy") +} +onready var _summary = { + control = $VBox/TitleBar/HBox/Summary, + failing = $VBox/TitleBar/HBox/Summary/Failing, + passing = $VBox/TitleBar/HBox/Summary/Passing, + asserts = $VBox/TitleBar/HBox/Summary/AssertCount, + fail_count = 0, + pass_count = 0 +} + +onready var _extras = $ExtraOptions +onready var _ignore_pauses = $ExtraOptions/IgnorePause +onready var _continue_button = $VBox/BottomPanel/VBox/HBox/Continue/Continue +onready var _text_box = $VBox/TextDisplay/RichTextLabel +onready var _text_box_container = $VBox/TextDisplay +onready var _log_level_slider = $VBox/BottomPanel/VBox/HBox2/LogLevelSlider +onready var _resize_handle = $ResizeHandle +onready var _current_script = $VBox/BottomPanel/VBox/HBox2/CurrentScriptLabel +onready var _title_replacement = $VBox/TitleBar/HBox/TitleReplacement + +onready var _titlebar = { + bar = $VBox/TitleBar, time = $VBox/TitleBar/HBox/Time, label = $VBox/TitleBar/HBox/Title +} + +onready var _user_files = $UserFileViewer + +var _mouse = {down = false, in_title = false, down_pos = null, in_handle = false} + +var _is_running = false +var _start_time = 0.0 +var _time = 0.0 + +const DEFAULT_TITLE = "GUT" +var _pre_maximize_rect = null +var _font_size = 20 +var _compact_mode = false + +var min_sizes = { + compact = Vector2(330, 100), + full = Vector2(740, 300), +} + +signal end_pause +signal ignore_pause +signal log_level_changed +signal run_script +signal run_single_script + + +func _ready(): + if Engine.editor_hint: + return + + _current_script.text = "" + _pre_maximize_rect = get_rect() + _hide_scripts() + _update_controls() + _nav.current_script.set_text("No scripts available") + set_title() + clear_summary() + _titlebar.time.set_text("t: 0.0") + + _extras.visible = false + update() + + set_font_size(_font_size) + set_font("CourierPrime") + + _user_files.set_position(Vector2(10, 30)) + + +func elapsed_time_as_str(): + return str("%.1f" % (_time / 1000.0), "s") + + +func _process(_delta): + if _is_running: + _time = OS.get_ticks_msec() - _start_time + _titlebar.time.set_text(str("t: ", elapsed_time_as_str())) + + +func _draw(): # needs get_size() + # Draw the lines in the corner to show where you can + # drag to resize the dialog + var grab_margin = 3 + var line_space = 3 + var grab_line_color = Color(.4, .4, .4) + if _resize_handle.visible: + for i in range(1, 10): + var x = rect_size - Vector2(i * line_space, grab_margin) + var y = rect_size - Vector2(grab_margin, i * line_space) + draw_line(x, y, grab_line_color, 1, true) + + +func _on_Maximize_draw(): + # draw the maximize square thing. + var btn = $VBox/TitleBar/HBox/Maximize + btn.set_text("") + var w = btn.get_size().x + var h = btn.get_size().y + btn.draw_rect(Rect2(0, 2, w, h - 2), Color(0, 0, 0, 1)) + btn.draw_rect(Rect2(2, 6, w - 4, h - 8), Color(1, 1, 1, 1)) + + +func _on_ShowExtras_draw(): + var btn = $VBox/BottomPanel/VBox/HBox/Continue/ShowExtras + btn.set_text("") + var start_x = 20 + var start_y = 15 + var pad = 5 + var color = Color(.1, .1, .1, 1) + var width = 2 + for i in range(3): + var y = start_y + pad * i + btn.draw_line( + Vector2(start_x, y), Vector2(btn.get_size().x - start_x, y), color, width, true + ) + + +# #################### +# GUI Events +# #################### +func _on_Run_pressed(): + _run_mode() + emit_signal("run_script", get_selected_index()) + + +func _on_CurrentScript_pressed(): + _toggle_scripts() + + +func _on_Previous_pressed(): + _select_script(get_selected_index() - 1) + + +func _on_Next_pressed(): + _select_script(get_selected_index() + 1) + + +func _on_LogLevelSlider_value_changed(_value): + emit_signal("log_level_changed", _log_level_slider.value) + + +func _on_Continue_pressed(): + _continue_button.disabled = true + emit_signal("end_pause") + + +func _on_IgnorePause_pressed(): + var checked = _ignore_pauses.is_pressed() + emit_signal("ignore_pause", checked) + if checked: + emit_signal("end_pause") + _continue_button.disabled = true + + +func _on_RunSingleScript_pressed(): + _run_mode() + emit_signal("run_single_script", get_selected_index()) + + +func _on_ScriptsList_item_selected(index): + var tmr = $ScriptsList/DoubleClickTimer + if !tmr.is_stopped(): + _run_mode() + emit_signal("run_single_script", get_selected_index()) + tmr.stop() + else: + tmr.start() + + _select_script(index) + + +func _on_TitleBar_mouse_entered(): + _mouse.in_title = true + + +func _on_TitleBar_mouse_exited(): + _mouse.in_title = false + + +func _input(event): + if event is InputEventMouseButton: + if event.button_index == 1: + _mouse.down = event.pressed + if _mouse.down: + _mouse.down_pos = event.position + + if _mouse.in_title: + if event is InputEventMouseMotion and _mouse.down: + set_position(get_position() + (event.position - _mouse.down_pos)) + _mouse.down_pos = event.position + _pre_maximize_rect = get_rect() + + if _mouse.in_handle: + if event is InputEventMouseMotion and _mouse.down: + var new_size = rect_size + event.position - _mouse.down_pos + var new_mouse_down_pos = event.position + rect_size = new_size + _mouse.down_pos = new_mouse_down_pos + _pre_maximize_rect = get_rect() + + +func _on_ResizeHandle_mouse_entered(): + _mouse.in_handle = true + + +func _on_ResizeHandle_mouse_exited(): + _mouse.in_handle = false + + +func _on_RichTextLabel_gui_input(ev): + pass + # leaving this b/c it is wired up and might have to send + # more signals through + + +func _on_Copy_pressed(): + OS.clipboard = _text_box.text + + +func _on_ShowExtras_toggled(button_pressed): + _extras.visible = button_pressed + + +func _on_Maximize_pressed(): + if get_rect() == _pre_maximize_rect: + compact_mode(false) + maximize() + else: + compact_mode(false) + rect_size = _pre_maximize_rect.size + rect_position = _pre_maximize_rect.position + + +func _on_Minimize_pressed(): + compact_mode(!_compact_mode) + + +func _on_Minimize_draw(): + # draw the maximize square thing. + var btn = $VBox/TitleBar/HBox/Minimize + btn.set_text("") + var w = btn.get_size().x + var h = btn.get_size().y + btn.draw_rect(Rect2(0, h - 3, w, 3), Color(0, 0, 0, 1)) + + +func _on_UserFiles_pressed(): + _user_files.show_open() + + +# #################### +# Private +# #################### +func _run_mode(is_running = true): + if is_running: + _start_time = OS.get_ticks_msec() + _time = 0.0 + clear_summary() + _is_running = is_running + + _hide_scripts() + _nav.prev.disabled = is_running + _nav.next.disabled = is_running + _nav.run.disabled = is_running + _nav.current_script.disabled = is_running + _nav.run_single.disabled = is_running + + +func _select_script(index): + var text = _script_list.get_item_text(index) + var max_len = 50 + if text.length() > max_len: + text = "..." + text.right(text.length() - (max_len - 5)) + _nav.current_script.set_text(text) + _script_list.select(index) + _update_controls() + + +func _toggle_scripts(): + if _script_list.visible: + _hide_scripts() + else: + _show_scripts() + + +func _show_scripts(): + _script_list.show() + + +func _hide_scripts(): + _script_list.hide() + + +func _update_controls(): + var is_empty = _script_list.get_selected_items().size() == 0 + if is_empty: + _nav.next.disabled = true + _nav.prev.disabled = true + else: + var index = get_selected_index() + _nav.prev.disabled = index <= 0 + _nav.next.disabled = index >= _script_list.get_item_count() - 1 + + _nav.run.disabled = is_empty + _nav.current_script.disabled = is_empty + _nav.run_single.disabled = is_empty + + +func _update_summary(): + if !_summary: + return + + var total = _summary.fail_count + _summary.pass_count + _summary.control.visible = !total == 0 + _summary.asserts.text = str("Failures ", _summary.fail_count, "/", total) + + +# #################### +# Public +# #################### +func run_mode(is_running = true): + _run_mode(is_running) + + +func set_scripts(scripts): + _script_list.clear() + for i in range(scripts.size()): + _script_list.add_item(scripts[i]) + _select_script(0) + _update_controls() + + +func select_script(index): + _select_script(index) + + +func get_selected_index(): + return _script_list.get_selected_items()[0] + + +func get_log_level(): + return _log_level_slider.value + + +func set_log_level(value): + var new_value = value + if new_value == null: + new_value = 0 + # !! For some reason, _log_level_slider was null, but this wasn't, so + # here's another hardcoded node path. + $VBox/BottomPanel/VBox/HBox2/LogLevelSlider.value = new_value + + +func set_ignore_pause(should): + _ignore_pauses.pressed = should + + +func get_ignore_pause(): + return _ignore_pauses.pressed + + +func get_text_box(): + # due to some timing issue, this cannot return _text_box but can return + # this. + return $VBox/TextDisplay/RichTextLabel + + +func end_run(): + _run_mode(false) + _update_controls() + + +func set_progress_script_max(value): + var max_val = max(value, 1) + _progress.script.set_max(max_val) + _progress.script_xy.set_text(str("0/", max_val)) + + +func set_progress_script_value(value): + _progress.script.set_value(value) + var txt = str(value, "/", _progress.test.get_max()) + _progress.script_xy.set_text(txt) + + +func set_progress_test_max(value): + var max_val = max(value, 1) + _progress.test.set_max(max_val) + _progress.test_xy.set_text(str("0/", max_val)) + + +func set_progress_test_value(value): + _progress.test.set_value(value) + var txt = str(value, "/", _progress.test.get_max()) + _progress.test_xy.set_text(txt) + + +func clear_progress(): + _progress.test.set_value(0) + _progress.script.set_value(0) + + +func pause(): + _continue_button.disabled = false + + +func set_title(title = null): + if title == null: + _titlebar.label.set_text(DEFAULT_TITLE) + else: + _titlebar.label.set_text(title) + + +func add_passing(amount = 1): + if !_summary: + return + _summary.pass_count += amount + _update_summary() + + +func add_failing(amount = 1): + if !_summary: + return + _summary.fail_count += amount + _update_summary() + + +func clear_summary(): + _summary.fail_count = 0 + _summary.pass_count = 0 + _update_summary() + + +func maximize(): + if is_inside_tree(): + var vp_size_offset = get_tree().root.get_viewport().get_visible_rect().size + rect_size = vp_size_offset / get_scale() + set_position(Vector2(0, 0)) + + +func clear_text(): + _text_box.bbcode_text = "" + + +func scroll_to_bottom(): + pass + #_text_box.cursor_set_line(_gui.get_text_box().get_line_count()) + + +func _set_font_size_for_rtl(rtl, new_size): + if rtl.get("custom_fonts/normal_font") != null: + rtl.get("custom_fonts/bold_italics_font").size = new_size + rtl.get("custom_fonts/bold_font").size = new_size + rtl.get("custom_fonts/italics_font").size = new_size + rtl.get("custom_fonts/normal_font").size = new_size + + +func _set_fonts_for_rtl(rtl, base_font_name): + pass + + +func set_font_size(new_size): + _font_size = new_size + _set_font_size_for_rtl(_text_box, new_size) + _set_font_size_for_rtl(_user_files.get_rich_text_label(), new_size) + + +func _set_font(rtl, font_name, custom_name): + if font_name == null: + rtl.set("custom_fonts/" + custom_name, null) + else: + var dyn_font = preload("res://addons/godot_xterm/themes/fonts/regular.tres").duplicate() + rtl.set("custom_fonts/" + custom_name, dyn_font) + + +func _set_all_fonts_in_ftl(ftl, base_name): + if base_name == "Default": + _set_font(ftl, null, "normal_font") + _set_font(ftl, null, "bold_font") + _set_font(ftl, null, "italics_font") + _set_font(ftl, null, "bold_italics_font") + else: + _set_font(ftl, base_name + "-Regular", "normal_font") + _set_font(ftl, base_name + "-Bold", "bold_font") + _set_font(ftl, base_name + "-Italic", "italics_font") + _set_font(ftl, base_name + "-BoldItalic", "bold_italics_font") + set_font_size(_font_size) + + +func set_font(base_name): + _set_all_fonts_in_ftl(_text_box, base_name) + _set_all_fonts_in_ftl(_user_files.get_rich_text_label(), base_name) + + +func set_default_font_color(color): + _text_box.set("custom_colors/default_color", color) + + +func set_background_color(color): + _text_box_container.color = color + + +func get_waiting_label(): + return $VBox/TextDisplay/WaitingLabel + + +func compact_mode(should): + if _compact_mode == should: + return + + _compact_mode = should + _text_box_container.visible = !should + _nav.container.visible = !should + _log_level_slider.visible = !should + $VBox/BottomPanel/VBox/HBox/Continue/ShowExtras.visible = !should + _titlebar.label.visible = !should + _resize_handle.visible = !should + _current_script.visible = !should + _title_replacement.visible = should + + if should: + rect_min_size = min_sizes.compact + rect_size = rect_min_size + else: + rect_min_size = min_sizes.full + rect_size = min_sizes.full + + goto_bottom_right_corner() + + +func set_script_path(text): + _current_script.text = text + + +func goto_bottom_right_corner(): + rect_position = get_tree().root.get_viewport().get_visible_rect().size - rect_size diff --git a/addons/gut/GutScene.tscn b/addons/gut/GutScene.tscn new file mode 100644 index 0000000..ce7e855 --- /dev/null +++ b/addons/gut/GutScene.tscn @@ -0,0 +1,617 @@ +[gd_scene load_steps=16 format=2] + +[ext_resource path="res://addons/gut/GutScene.gd" type="Script" id=1] +[ext_resource path="res://addons/gut/UserFileViewer.tscn" type="PackedScene" id=6] + +[sub_resource type="StyleBoxFlat" id=1] +bg_color = Color( 0.192157, 0.192157, 0.227451, 1 ) +corner_radius_top_left = 10 +corner_radius_top_right = 10 + +[sub_resource type="StyleBoxFlat" id=2] +bg_color = Color( 1, 1, 1, 1 ) +border_color = Color( 0, 0, 0, 1 ) +corner_radius_top_left = 5 +corner_radius_top_right = 5 + +[sub_resource type="Theme" id=3] +resource_local_to_scene = true +Panel/styles/panel = SubResource( 2 ) +Panel/styles/panelf = null +Panel/styles/panelnc = null + +[sub_resource type="StyleBoxFlat" id=8] +bg_color = Color( 0.192157, 0.192157, 0.227451, 1 ) +corner_radius_top_left = 20 +corner_radius_top_right = 20 + +[node name="Gut" type="Panel"] +margin_right = 740.0 +margin_bottom = 300.0 +rect_min_size = Vector2( 740, 300 ) +custom_styles/panel = SubResource( 1 ) +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="UserFileViewer" parent="." instance=ExtResource( 6 )] +margin_top = 388.0 +margin_bottom = 818.0 + +[node name="VBox" type="VBoxContainer" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="TitleBar" type="Panel" parent="VBox"] +margin_right = 740.0 +margin_bottom = 30.0 +rect_min_size = Vector2( 0, 30 ) +theme = SubResource( 3 ) +__meta__ = { +"_edit_group_": true, +"_edit_use_anchors_": false +} + +[node name="HBox" type="HBoxContainer" parent="VBox/TitleBar"] +anchor_right = 1.0 +anchor_bottom = 1.0 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Summary" type="Control" parent="VBox/TitleBar/HBox"] +margin_right = 110.0 +margin_bottom = 30.0 +rect_min_size = Vector2( 110, 0 ) +mouse_filter = 2 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Passing" type="Label" parent="VBox/TitleBar/HBox/Summary"] +visible = false +margin_left = 5.0 +margin_top = 7.0 +margin_right = 45.0 +margin_bottom = 21.0 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "0" +align = 1 +valign = 1 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Failing" type="Label" parent="VBox/TitleBar/HBox/Summary"] +visible = false +margin_left = 100.0 +margin_top = 7.0 +margin_right = 140.0 +margin_bottom = 21.0 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "0" +align = 1 +valign = 1 + +[node name="AssertCount" type="Label" parent="VBox/TitleBar/HBox/Summary"] +margin_left = 5.0 +margin_top = 7.0 +margin_right = 165.0 +margin_bottom = 21.0 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "Assert count" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="TitleReplacement" type="CenterContainer" parent="VBox/TitleBar/HBox"] +visible = false +margin_left = 114.0 +margin_right = 352.0 +margin_bottom = 30.0 +rect_min_size = Vector2( 5, 0 ) +mouse_filter = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Title" type="Label" parent="VBox/TitleBar/HBox"] +margin_left = 114.0 +margin_right = 594.0 +margin_bottom = 30.0 +size_flags_horizontal = 3 +size_flags_vertical = 7 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "Gut" +align = 1 +valign = 1 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Time" type="Label" parent="VBox/TitleBar/HBox"] +margin_left = 598.0 +margin_top = 7.0 +margin_right = 654.0 +margin_bottom = 22.0 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "9999.99" +valign = 1 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="CC" type="CenterContainer" parent="VBox/TitleBar/HBox"] +margin_left = 658.0 +margin_right = 663.0 +margin_bottom = 30.0 +rect_min_size = Vector2( 5, 0 ) +mouse_filter = 2 + +[node name="Minimize" type="Button" parent="VBox/TitleBar/HBox"] +margin_left = 667.0 +margin_right = 697.0 +margin_bottom = 30.0 +rect_min_size = Vector2( 30, 0 ) +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "N" +flat = true +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Maximize" type="Button" parent="VBox/TitleBar/HBox"] +margin_left = 701.0 +margin_right = 731.0 +margin_bottom = 30.0 +rect_min_size = Vector2( 30, 0 ) +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "X" +flat = true +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="CC2" type="CenterContainer" parent="VBox/TitleBar/HBox"] +margin_left = 735.0 +margin_right = 740.0 +margin_bottom = 30.0 +rect_min_size = Vector2( 5, 0 ) +mouse_filter = 2 + +[node name="TextDisplay" type="ColorRect" parent="VBox"] +margin_top = 34.0 +margin_right = 740.0 +margin_bottom = 176.0 +size_flags_horizontal = 3 +size_flags_vertical = 3 +color = Color( 0, 0, 0, 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="RichTextLabel" type="RichTextLabel" parent="VBox/TextDisplay"] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 10.0 +rect_min_size = Vector2( 0, 116 ) +focus_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +bbcode_enabled = true +scroll_following = true +selection_enabled = true +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="WaitingLabel" type="RichTextLabel" parent="VBox/TextDisplay"] +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_top = -25.0 +bbcode_enabled = true +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="BottomPanel" type="ColorRect" parent="VBox"] +margin_top = 180.0 +margin_right = 740.0 +margin_bottom = 300.0 +rect_min_size = Vector2( 0, 120 ) +size_flags_horizontal = 9 +size_flags_vertical = 9 +color = Color( 1, 1, 1, 0 ) + +[node name="VBox" type="VBoxContainer" parent="VBox/BottomPanel"] +anchor_right = 1.0 +anchor_bottom = 1.0 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="HBox" type="HBoxContainer" parent="VBox/BottomPanel/VBox"] +margin_right = 740.0 +margin_bottom = 80.0 +size_flags_horizontal = 3 + +[node name="CC1" type="CenterContainer" parent="VBox/BottomPanel/VBox/HBox"] +margin_right = 5.0 +margin_bottom = 80.0 +rect_min_size = Vector2( 5, 0 ) + +[node name="Progress" type="VBoxContainer" parent="VBox/BottomPanel/VBox/HBox"] +margin_left = 9.0 +margin_right = 179.0 +margin_bottom = 80.0 +rect_min_size = Vector2( 170, 0 ) +alignment = 1 + +[node name="TestProgress" type="ProgressBar" parent="VBox/BottomPanel/VBox/HBox/Progress"] +margin_top = 11.0 +margin_right = 100.0 +margin_bottom = 36.0 +rect_min_size = Vector2( 100, 25 ) +hint_tooltip = "Test progress for the current script." +size_flags_horizontal = 0 +step = 1.0 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Label" type="Label" parent="VBox/BottomPanel/VBox/HBox/Progress/TestProgress"] +margin_left = 107.5 +margin_top = 3.0 +margin_right = 172.5 +margin_bottom = 18.0 +text = "Tests" +valign = 1 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="xy" type="Label" parent="VBox/BottomPanel/VBox/HBox/Progress/TestProgress"] +visible = false +anchor_right = 1.0 +anchor_bottom = 1.0 +text = "0/0" +align = 1 +valign = 1 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="ScriptProgress" type="ProgressBar" parent="VBox/BottomPanel/VBox/HBox/Progress"] +margin_top = 40.0 +margin_right = 100.0 +margin_bottom = 65.0 +rect_min_size = Vector2( 100, 25 ) +hint_tooltip = "Overall progress of executing tests." +size_flags_horizontal = 0 +step = 1.0 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Label" type="Label" parent="VBox/BottomPanel/VBox/HBox/Progress/ScriptProgress"] +margin_left = 107.0 +margin_top = 3.5 +margin_right = 172.0 +margin_bottom = 18.5 +text = "Scripts" +valign = 1 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="xy" type="Label" parent="VBox/BottomPanel/VBox/HBox/Progress/ScriptProgress"] +visible = false +anchor_right = 1.0 +anchor_bottom = 1.0 +text = "0/0" +align = 1 +valign = 1 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="CenterContainer" type="CenterContainer" parent="VBox/BottomPanel/VBox/HBox/Progress"] +margin_top = 69.0 +margin_right = 170.0 +margin_bottom = 69.0 + +[node name="CC2" type="CenterContainer" parent="VBox/BottomPanel/VBox/HBox"] +margin_left = 183.0 +margin_right = 226.0 +margin_bottom = 80.0 +rect_min_size = Vector2( 5, 0 ) +size_flags_horizontal = 3 + +[node name="Navigation" type="Panel" parent="VBox/BottomPanel/VBox/HBox"] +self_modulate = Color( 1, 1, 1, 0 ) +margin_left = 230.0 +margin_right = 580.0 +margin_bottom = 80.0 +rect_min_size = Vector2( 350, 80 ) +__meta__ = { +"_edit_group_": true, +"_edit_use_anchors_": false +} + +[node name="VBox" type="VBoxContainer" parent="VBox/BottomPanel/VBox/HBox/Navigation"] +anchor_right = 1.0 +anchor_bottom = 1.0 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="CurrentScript" type="Button" parent="VBox/BottomPanel/VBox/HBox/Navigation/VBox"] +margin_right = 350.0 +margin_bottom = 38.0 +hint_tooltip = "Select a script to run. You can run just this script, or this script and all scripts after using the run buttons." +size_flags_horizontal = 3 +size_flags_vertical = 3 +text = "res://test/unit/test_gut.gd" +clip_text = true +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="HBox" type="HBoxContainer" parent="VBox/BottomPanel/VBox/HBox/Navigation/VBox"] +margin_top = 42.0 +margin_right = 350.0 +margin_bottom = 80.0 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Previous" type="Button" parent="VBox/BottomPanel/VBox/HBox/Navigation/VBox/HBox"] +margin_right = 84.0 +margin_bottom = 38.0 +hint_tooltip = "Previous script in the list." +size_flags_horizontal = 3 +size_flags_vertical = 3 +text = "|<" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Next" type="Button" parent="VBox/BottomPanel/VBox/HBox/Navigation/VBox/HBox"] +margin_left = 88.0 +margin_right = 173.0 +margin_bottom = 38.0 +hint_tooltip = "Next script in the list. +" +size_flags_horizontal = 3 +size_flags_vertical = 3 +text = ">|" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Run" type="Button" parent="VBox/BottomPanel/VBox/HBox/Navigation/VBox/HBox"] +margin_left = 177.0 +margin_right = 261.0 +margin_bottom = 38.0 +hint_tooltip = "Run the currently selected item and all after it." +size_flags_horizontal = 3 +size_flags_vertical = 3 +text = ">" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="RunSingleScript" type="Button" parent="VBox/BottomPanel/VBox/HBox/Navigation/VBox/HBox"] +margin_left = 265.0 +margin_right = 350.0 +margin_bottom = 38.0 +hint_tooltip = "Run the currently selected item. + +If the selected item has Inner Test Classes +then they will all be run. If the selected item +is an Inner Test Class then only it will be run." +size_flags_horizontal = 3 +size_flags_vertical = 3 +text = "> (1)" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="CC3" type="CenterContainer" parent="VBox/BottomPanel/VBox/HBox"] +margin_left = 584.0 +margin_right = 627.0 +margin_bottom = 80.0 +rect_min_size = Vector2( 5, 0 ) +size_flags_horizontal = 3 + +[node name="Continue" type="VBoxContainer" parent="VBox/BottomPanel/VBox/HBox"] +self_modulate = Color( 1, 1, 1, 0 ) +margin_left = 631.0 +margin_right = 731.0 +margin_bottom = 80.0 +alignment = 1 + +[node name="ShowExtras" type="Button" parent="VBox/BottomPanel/VBox/HBox/Continue"] +margin_right = 50.0 +margin_bottom = 35.0 +rect_min_size = Vector2( 50, 35 ) +rect_pivot_offset = Vector2( 35, 20 ) +hint_tooltip = "Show/hide additional options." +size_flags_horizontal = 0 +toggle_mode = true +text = "_" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Continue" type="Button" parent="VBox/BottomPanel/VBox/HBox/Continue"] +margin_top = 39.0 +margin_right = 100.0 +margin_bottom = 79.0 +rect_min_size = Vector2( 100, 40 ) +hint_tooltip = "When a pause_before_teardown is encountered this button will be enabled and must be pressed to continue running tests." +disabled = true +text = "Continue" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="CC4" type="CenterContainer" parent="VBox/BottomPanel/VBox/HBox"] +margin_left = 735.0 +margin_right = 740.0 +margin_bottom = 80.0 +rect_min_size = Vector2( 5, 0 ) + +[node name="HBox2" type="HBoxContainer" parent="VBox/BottomPanel/VBox"] +margin_top = 84.0 +margin_right = 740.0 +margin_bottom = 114.0 + +[node name="CC" type="CenterContainer" parent="VBox/BottomPanel/VBox/HBox2"] +margin_right = 5.0 +margin_bottom = 30.0 +rect_min_size = Vector2( 5, 0 ) + +[node name="LogLevelSlider" type="HSlider" parent="VBox/BottomPanel/VBox/HBox2"] +margin_left = 9.0 +margin_right = 109.0 +margin_bottom = 30.0 +rect_min_size = Vector2( 100, 30 ) +size_flags_vertical = 3 +max_value = 2.0 +tick_count = 3 +ticks_on_borders = true +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Label" type="Label" parent="VBox/BottomPanel/VBox/HBox2/LogLevelSlider"] +margin_left = 4.0 +margin_top = -17.0 +margin_right = 85.0 +margin_bottom = 7.0 +text = "Log Level" +valign = 1 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="CenterContainer" type="CenterContainer" parent="VBox/BottomPanel/VBox/HBox2"] +margin_left = 113.0 +margin_right = 163.0 +margin_bottom = 30.0 +rect_min_size = Vector2( 50, 0 ) + +[node name="CurrentScriptLabel" type="Label" parent="VBox/BottomPanel/VBox/HBox2"] +margin_left = 167.0 +margin_top = 7.0 +margin_right = 740.0 +margin_bottom = 22.0 +size_flags_horizontal = 3 +size_flags_vertical = 6 +text = "res://test/unit/test_something.gd" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="ScriptsList" type="ItemList" parent="."] +visible = false +anchor_bottom = 1.0 +margin_left = 179.0 +margin_top = 40.0 +margin_right = 619.0 +margin_bottom = -110.0 +allow_reselect = true +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="DoubleClickTimer" type="Timer" parent="ScriptsList"] +wait_time = 0.3 +one_shot = true + +[node name="ExtraOptions" type="Panel" parent="."] +visible = false +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = -212.0 +margin_top = -260.0 +margin_right = -2.0 +margin_bottom = -106.0 +custom_styles/panel = SubResource( 8 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="IgnorePause" type="CheckBox" parent="ExtraOptions"] +margin_left = 17.5 +margin_top = 4.5 +margin_right = 162.5 +margin_bottom = 29.5 +rect_scale = Vector2( 1.2, 1.2 ) +hint_tooltip = "Ignore all calls to pause_before_teardown." +text = "Ignore Pauses" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Copy" type="Button" parent="ExtraOptions"] +margin_left = 15.0 +margin_top = 40.0 +margin_right = 195.0 +margin_bottom = 80.0 +hint_tooltip = "Copy all output to the clipboard." +text = "Copy to Clipboard" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="UserFiles" type="Button" parent="ExtraOptions"] +margin_left = 15.0 +margin_top = 90.0 +margin_right = 195.0 +margin_bottom = 130.0 +hint_tooltip = "Copy all output to the clipboard." +text = "View User Files" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="ResizeHandle" type="Control" parent="."] +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = -40.0 +margin_top = -40.0 +__meta__ = { +"_edit_use_anchors_": false +} + +[connection signal="mouse_entered" from="VBox/TitleBar" to="." method="_on_TitleBar_mouse_entered"] +[connection signal="mouse_exited" from="VBox/TitleBar" to="." method="_on_TitleBar_mouse_exited"] +[connection signal="draw" from="VBox/TitleBar/HBox/Minimize" to="." method="_on_Minimize_draw"] +[connection signal="pressed" from="VBox/TitleBar/HBox/Minimize" to="." method="_on_Minimize_pressed"] +[connection signal="draw" from="VBox/TitleBar/HBox/Maximize" to="." method="_on_Maximize_draw"] +[connection signal="pressed" from="VBox/TitleBar/HBox/Maximize" to="." method="_on_Maximize_pressed"] +[connection signal="gui_input" from="VBox/TextDisplay/RichTextLabel" to="." method="_on_RichTextLabel_gui_input"] +[connection signal="pressed" from="VBox/BottomPanel/VBox/HBox/Navigation/VBox/CurrentScript" to="." method="_on_CurrentScript_pressed"] +[connection signal="pressed" from="VBox/BottomPanel/VBox/HBox/Navigation/VBox/HBox/Previous" to="." method="_on_Previous_pressed"] +[connection signal="pressed" from="VBox/BottomPanel/VBox/HBox/Navigation/VBox/HBox/Next" to="." method="_on_Next_pressed"] +[connection signal="pressed" from="VBox/BottomPanel/VBox/HBox/Navigation/VBox/HBox/Run" to="." method="_on_Run_pressed"] +[connection signal="pressed" from="VBox/BottomPanel/VBox/HBox/Navigation/VBox/HBox/RunSingleScript" to="." method="_on_RunSingleScript_pressed"] +[connection signal="draw" from="VBox/BottomPanel/VBox/HBox/Continue/ShowExtras" to="." method="_on_ShowExtras_draw"] +[connection signal="toggled" from="VBox/BottomPanel/VBox/HBox/Continue/ShowExtras" to="." method="_on_ShowExtras_toggled"] +[connection signal="pressed" from="VBox/BottomPanel/VBox/HBox/Continue/Continue" to="." method="_on_Continue_pressed"] +[connection signal="value_changed" from="VBox/BottomPanel/VBox/HBox2/LogLevelSlider" to="." method="_on_LogLevelSlider_value_changed"] +[connection signal="item_selected" from="ScriptsList" to="." method="_on_ScriptsList_item_selected"] +[connection signal="pressed" from="ExtraOptions/IgnorePause" to="." method="_on_IgnorePause_pressed"] +[connection signal="pressed" from="ExtraOptions/Copy" to="." method="_on_Copy_pressed"] +[connection signal="pressed" from="ExtraOptions/UserFiles" to="." method="_on_UserFiles_pressed"] +[connection signal="mouse_entered" from="ResizeHandle" to="." method="_on_ResizeHandle_mouse_entered"] +[connection signal="mouse_exited" from="ResizeHandle" to="." method="_on_ResizeHandle_mouse_exited"] diff --git a/addons/gd-plug/LICENSE b/addons/gut/LICENSE.md similarity index 84% rename from addons/gd-plug/LICENSE rename to addons/gut/LICENSE.md index 37b2749..a38ac23 100644 --- a/addons/gd-plug/LICENSE +++ b/addons/gut/LICENSE.md @@ -1,6 +1,7 @@ -MIT License +The MIT License (MIT) +===================== -Copyright (c) 2021 Tan Jian Ping +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 @@ -9,13 +10,13 @@ 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 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. +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..ba6e802 --- /dev/null +++ b/addons/gut/UserFileViewer.gd @@ -0,0 +1,66 @@ +extends WindowDialog + +onready var rtl = $TextDisplay/RichTextLabel +var _has_opened_file = false + + +func _get_file_as_text(path): + var to_return = null + var f = File.new() + var result = f.open(path, f.READ) + if result == OK: + to_return = f.get_as_text() + f.close() + else: + to_return = str("ERROR: Could not open file. Error code ", result) + 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 _on_FileDialog_popup_hide(): + if rtl.text.length() == 0: + self.hide() + + +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(): + OS.clipboard = rtl.text diff --git a/addons/gut/UserFileViewer.tscn b/addons/gut/UserFileViewer.tscn new file mode 100644 index 0000000..528d1cd --- /dev/null +++ b/addons/gut/UserFileViewer.tscn @@ -0,0 +1,128 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://addons/gut/UserFileViewer.gd" type="Script" id=1] + +[node name="UserFileViewer" type="WindowDialog"] +margin_top = 20.0 +margin_right = 800.0 +margin_bottom = 450.0 +rect_min_size = Vector2( 800, 180 ) +popup_exclusive = true +window_title = "View File" +resizable = true +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="FileDialog" type="FileDialog" parent="."] +margin_right = 416.0 +margin_bottom = 184.0 +rect_min_size = Vector2( 400, 140 ) +rect_scale = Vector2( 2, 2 ) +popup_exclusive = true +window_title = "Open a File" +resizable = true +mode = 0 +access = 1 +show_hidden_files = true +current_dir = "user://" +current_path = "user://" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="TextDisplay" type="ColorRect" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = 8.0 +margin_right = -10.0 +margin_bottom = -65.0 +color = Color( 0.2, 0.188235, 0.188235, 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[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 +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="OpenFile" type="Button" parent="."] +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = -158.0 +margin_top = -50.0 +margin_right = -84.0 +margin_bottom = -30.0 +rect_scale = Vector2( 2, 2 ) +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 +margin_left = -478.0 +margin_top = -50.0 +margin_right = -404.0 +margin_bottom = -30.0 +rect_scale = Vector2( 2, 2 ) +text = "Home" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="Copy" type="Button" parent="."] +anchor_top = 1.0 +anchor_bottom = 1.0 +margin_left = 160.0 +margin_top = -50.0 +margin_right = 234.0 +margin_bottom = -30.0 +rect_scale = Vector2( 2, 2 ) +text = "Copy" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="End" type="Button" parent="."] +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = -318.0 +margin_top = -50.0 +margin_right = -244.0 +margin_bottom = -30.0 +rect_scale = Vector2( 2, 2 ) +text = "End" + +[node name="Close" type="Button" parent="."] +anchor_top = 1.0 +anchor_bottom = 1.0 +margin_left = 10.0 +margin_top = -50.0 +margin_right = 80.0 +margin_bottom = -30.0 +rect_scale = Vector2( 2, 2 ) +text = "Close" + +[connection signal="file_selected" from="FileDialog" to="." method="_on_FileDialog_file_selected"] +[connection signal="popup_hide" from="FileDialog" to="." method="_on_FileDialog_popup_hide"] +[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..b4afec8 --- /dev/null +++ b/addons/gut/autofree.gd @@ -0,0 +1,62 @@ +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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 = [] + + +func add_free(thing): + if typeof(thing) == TYPE_OBJECT: + if !thing is Reference: + _to_free.append(thing) + + +func add_queue_free(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 i in range(_to_free.size()): + if is_instance_valid(_to_free[i]): + _to_free[i].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() diff --git a/addons/gut/comparator.gd b/addons/gut/comparator.gd new file mode 100644 index 0000000..d58eb7c --- /dev/null +++ b/addons/gut/comparator.gd @@ -0,0 +1,134 @@ +var _utils = load("res://addons/gut/utils.gd").get_instance() +var _strutils = _utils.Strutils.new() +var _max_length = 100 +var _should_compare_int_to_float = true + +const MISSING = "|__missing__gut__compare__value__|" +const DICTIONARY_DISCLAIMER = "Dictionaries are compared-by-ref. See assert_eq in wiki." + + +func _cannot_comapre_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 = _utils.CompareResult.new() + elif typeof(v2) == TYPE_STRING and v2 == MISSING: + v2_str = _make_missing_string(text) + to_return = _utils.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 = _utils.CompareResult.new() + var cmp_str = null + var extra = "" + + if _should_compare_int_to_float and [2, 3].has(typeof(v1)) and [2, 3].has(typeof(v2)): + result.are_equal = v1 == v2 + + elif _utils.are_datatypes_same(v1, v2): + result.are_equal = v1 == v2 + if typeof(v1) == TYPE_DICTIONARY: + if result.are_equal: + extra = ". Same dictionary ref. " + else: + extra = ". Different dictionary refs. " + extra += DICTIONARY_DISCLAIMER + + if typeof(v1) == TYPE_ARRAY: + var array_result = _utils.DiffTool.new(v1, v2, _utils.DIFF.SHALLOW) + result.summary = array_result.get_short_summary() + if !array_result.are_equal(): + extra = ".\n" + array_result.get_short_summary() + + else: + cmp_str = "!=" + result.are_equal = false + extra = str(". ", _cannot_comapre_text(v1, v2)) + + cmp_str = get_compare_symbol(result.are_equal) + if typeof(v1) != TYPE_ARRAY: + result.summary = str(format_value(v1), " ", cmp_str, " ", format_value(v2), extra) + + return result + + +func shallow(v1, v2): + var result = null + + if _utils.are_datatypes_same(v1, v2): + if typeof(v1) in [TYPE_ARRAY, TYPE_DICTIONARY]: + result = _utils.DiffTool.new(v1, v2, _utils.DIFF.SHALLOW) + else: + result = simple(v1, v2) + else: + result = simple(v1, v2) + + return result + + +func deep(v1, v2): + var result = null + + if _utils.are_datatypes_same(v1, v2): + if typeof(v1) in [TYPE_ARRAY, TYPE_DICTIONARY]: + result = _utils.DiffTool.new(v1, v2, _utils.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 = _utils.DIFF.SIMPLE): + var result = null + if diff_type == _utils.DIFF.SIMPLE: + result = simple(v1, v2) + elif diff_type == _utils.DIFF.SHALLOW: + result = shallow(v1, v2) + elif diff_type == _utils.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/compare_result.gd b/addons/gut/compare_result.gd new file mode 100644 index 0000000..126c481 --- /dev/null +++ b/addons/gut/compare_result.gd @@ -0,0 +1,60 @@ +var are_equal = null setget set_are_equal, get_are_equal +var summary = null setget set_summary, get_summary +var max_differences = 30 setget set_max_differences, get_max_differences +var differences = {} setget set_differences, get_differences + + +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/diff_formatter.gd b/addons/gut/diff_formatter.gd new file mode 100644 index 0000000..b07d9e2 --- /dev/null +++ b/addons/gut/diff_formatter.gd @@ -0,0 +1,67 @@ +var _utils = load("res://addons/gut/utils.gd").get_instance() +var _strutils = _utils.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_tool.gd b/addons/gut/diff_tool.gd new file mode 100644 index 0000000..ba52205 --- /dev/null +++ b/addons/gut/diff_tool.gd @@ -0,0 +1,179 @@ +extends "res://addons/gut/compare_result.gd" +const INDENT = " " +enum { DEEP, SHALLOW, SIMPLE } + +var _utils = load("res://addons/gut/utils.gd").get_instance() +var _strutils = _utils.Strutils.new() +var _compare = _utils.Comparator.new() +var DiffTool = load("res://addons/gut/diff_tool.gd") + +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(): + return are_equal() + + +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 _utils.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() + _utils.get_logger().error("Only Arrays and Dictionaries are supported.") + else: + _invalidate() + _utils.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(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 are_equal(): + if !_valid: + return null + else: + return differences.size() == 0 + + +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/double_templates/function_template.txt b/addons/gut/double_templates/function_template.txt new file mode 100644 index 0000000..666952e --- /dev/null +++ b/addons/gut/double_templates/function_template.txt @@ -0,0 +1,6 @@ +{func_decleration} + __gut_spy('{method_name}', {param_array}) + if(__gut_should_call_super('{method_name}', {param_array})): + return {super_call} + else: + return __gut_get_stubbed_return('{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..b1e1d77 --- /dev/null +++ b/addons/gut/double_templates/script_template.txt @@ -0,0 +1,59 @@ +# ############################################################################## +# Start Script +# ############################################################################## +{extends} + +{constants} + +{properties} +# ------------------------------------------------------------------------------ +# GUT Double properties and methods +# ------------------------------------------------------------------------------ +var __gut_metadata_ = { + path = '{path}', + subpath = '{subpath}', + stubber = __gut_instance_from_id({stubber_id}), + spy = __gut_instance_from_id({spy_id}), + gut = __gut_instance_from_id({gut_id}), + from_singleton = '{singleton_name}', + is_partial = {is_partial} +} + +func __gut_instance_from_id(inst_id): + if(inst_id == -1): + return null + else: + return instance_from_id(inst_id) + +func __gut_should_call_super(method_name, called_with): + if(__gut_metadata_.stubber != null): + return __gut_metadata_.stubber.should_call_super(self, method_name, called_with) + else: + return false + +var __gut_utils_ = load('res://addons/gut/utils.gd').get_instance() + +func __gut_spy(method_name, called_with): + if(__gut_metadata_.spy != null): + __gut_metadata_.spy.add_call(self, method_name, called_with) + +func __gut_get_stubbed_return(method_name, called_with): + if(__gut_metadata_.stubber != null): + return __gut_metadata_.stubber.get_return(self, method_name, called_with) + else: + return null + +func __gut_default_val(method_name, p_index): + if(__gut_metadata_.stubber != null): + return __gut_metadata_.stubber.get_default_value(self, method_name, p_index) + else: + return null + + +func _init(): + if(__gut_metadata_.gut != null): + __gut_metadata_.gut.get_autofree().add_free(self) + +# ------------------------------------------------------------------------------ +# Methods start here +# ------------------------------------------------------------------------------ diff --git a/addons/gut/doubler.gd b/addons/gut/doubler.gd new file mode 100644 index 0000000..142050e --- /dev/null +++ b/addons/gut/doubler.gd @@ -0,0 +1,763 @@ +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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 +# ----------- +# ############################################################################## + +# ------------------------------------------------------------------------------ +# Utility class to hold the local and built in methods separately. Add all local +# methods FIRST, then add built ins. +# ------------------------------------------------------------------------------ +class ScriptMethods: + # List of methods that should not be overloaded when they are not defined + # in the class being doubled. These either break things if they are + # overloaded or do not have a "super" equivalent so we can't just pass + # through. + var _blacklist = [ + "has_method", + "get_script", + "get", + "_notification", + "get_path", + "_enter_tree", + "_exit_tree", + "_process", + "_draw", + "_physics_process", + "_input", + "_unhandled_input", + "_unhandled_key_input", + "_set", + "_get", # probably + "emit_signal", # can't handle extra parameters to be sent with signal. + "draw_mesh", # issue with one parameter, value is `Null((..), (..), (..))`` + "_to_string", # nonexistant function ._to_string + "_get_minimum_size", # Nonexistent function _get_minimum_size + ] + + # These methods should not be included in the double. + var _skip = [ + # There is an init in the template. There is also no real reason + # to include this method since it will always be called, it has no + # return value, and you cannot prevent super from being called. + "_init" + ] + + var built_ins = [] + var local_methods = [] + var _method_names = [] + + func is_blacklisted(method_meta): + return _blacklist.find(method_meta.name) != -1 + + func _add_name_if_does_not_have(method_name): + if _skip.has(method_name): + return false + var should_add = _method_names.find(method_name) == -1 + if should_add: + _method_names.append(method_name) + return should_add + + func add_built_in_method(method_meta): + var did_add = _add_name_if_does_not_have(method_meta.name) + if did_add and !is_blacklisted(method_meta): + built_ins.append(method_meta) + + func add_local_method(method_meta): + var did_add = _add_name_if_does_not_have(method_meta.name) + if did_add: + local_methods.append(method_meta) + + func to_s(): + var text = "Locals\n" + for i in range(local_methods.size()): + text += str(" ", local_methods[i].name, "\n") + text += "Built-Ins\n" + for i in range(built_ins.size()): + text += str(" ", built_ins[i].name, "\n") + return text + + +# ------------------------------------------------------------------------------ +# Helper class to deal with objects and inner classes. +# ------------------------------------------------------------------------------ +class ObjectInfo: + var _path = null + var _subpaths = [] + var _utils = load("res://addons/gut/utils.gd").get_instance() + var _lgr = _utils.get_logger() + var _method_strategy = null + var make_partial_double = false + var scene_path = null + var _native_class = null + var _native_class_name = null + var _singleton_instance = null + var _singleton_name = null + + func _init(path, subpath = null): + _path = path + if subpath != null: + _subpaths = Array(subpath.split("/")) + + # Returns an instance of the class/inner class + func instantiate(): + var to_return = null + + if _singleton_instance != null: + to_return = _singleton_instance + elif is_native(): + to_return = _native_class.new() + else: + to_return = get_loaded_class().new() + + return to_return + + # Can't call it get_class because that is reserved so it gets this ugly name. + # Loads up the class and then any inner classes to give back a reference to + # the desired Inner class (if there is any) + func get_loaded_class(): + var LoadedClass = load(_path) + for i in range(_subpaths.size()): + LoadedClass = LoadedClass.get(_subpaths[i]) + return LoadedClass + + func to_s(): + return str(_path, "[", get_subpath(), "]") + + func get_path(): + return _path + + func get_subpath(): + return PoolStringArray(_subpaths).join("/") + + func has_subpath(): + return _subpaths.size() != 0 + + func get_method_strategy(): + return _method_strategy + + func set_method_strategy(method_strategy): + _method_strategy = method_strategy + + func is_native(): + return _native_class != null + + func set_native_class(native_class): + _native_class = native_class + var inst = native_class.new() + _native_class_name = inst.get_class() + _path = _native_class_name + if !inst is Reference: + inst.free() + + func get_native_class_name(): + return _native_class_name + + func get_singleton_instance(): + return _singleton_instance + + func get_singleton_name(): + return _singleton_name + + func set_singleton_name(singleton_name): + _singleton_name = singleton_name + _singleton_instance = _utils.get_singleton_by_name(_singleton_name) + + func is_singleton(): + return _singleton_instance != null + + func get_extends_text(): + var extend = null + if is_singleton(): + extend = str("# Double of singleton ", _singleton_name, ", base class is Reference") + elif is_native(): + var native = get_native_class_name() + if native.begins_with("_"): + native = native.substr(1) + extend = str("extends ", native) + else: + extend = str("extends '", get_path(), "'") + + if has_subpath(): + extend += str(".", get_subpath().replace("/", ".")) + + return extend + + func get_constants_text(): + if !is_singleton(): + return "" + + # do not include constants defined in the super class which for + # singletons stubs is Reference. + var exclude_constants = Array(ClassDB.class_get_integer_constant_list("Reference")) + var text = str("# -----\n# ", _singleton_name, " Constants\n# -----\n") + var constants = ClassDB.class_get_integer_constant_list(_singleton_name) + for c in constants: + if !exclude_constants.has(c): + var value = ClassDB.class_get_integer_constant(_singleton_name, c) + text += str("const ", c, " = ", value, "\n") + + return text + + func get_properties_text(): + if !is_singleton(): + return "" + + var text = str("# -----\n# ", _singleton_name, " Properties\n# -----\n") + var props = ClassDB.class_get_property_list(_singleton_name) + for prop in props: + var accessors = {"setter": null, "getter": null} + var prop_text = str("var ", prop["name"]) + + var getter_name = "get_" + prop["name"] + if ClassDB.class_has_method(_singleton_name, getter_name): + accessors.getter = getter_name + else: + getter_name = "is_" + prop["name"] + if ClassDB.class_has_method(_singleton_name, getter_name): + accessors.getter = getter_name + + var setter_name = "set_" + prop["name"] + if ClassDB.class_has_method(_singleton_name, setter_name): + accessors.setter = setter_name + + var setget_text = "" + if accessors.setter != null and accessors.getter != null: + setget_text = str("setget ", accessors.setter, ", ", accessors.getter) + else: + # never seen this message show up, but it should show up if we + # get misbehaving singleton. + _lgr.error( + str( + "Could not find setget methods for property: ", + _singleton_name, + ".", + prop["name"] + ) + ) + + text += str(prop_text, " ", setget_text, "\n") + + return text + + +# ------------------------------------------------------------------------------ +# Allows for interacting with a file but only creating a string. This was done +# to ease the transition from files being created for doubles to loading +# doubles from a string. This allows the files to be created for debugging +# purposes since reading a file is easier than reading a dumped out string. +# ------------------------------------------------------------------------------ +class FileOrString: + extends File + + var _do_file = false + var _contents = "" + var _path = null + + func open(path, mode): + _path = path + if _do_file: + return .open(path, mode) + else: + return OK + + func close(): + if _do_file: + return .close() + + func store_string(s): + if _do_file: + .store_string(s) + _contents += s + + func get_contents(): + return _contents + + func get_path(): + return _path + + func load_it(): + if _contents != "": + var script = GDScript.new() + script.set_source_code(get_contents()) + script.reload() + return script + else: + return load(_path) + + +# ------------------------------------------------------------------------------ +# A stroke of genius if I do say so. This allows for doubling a scene without +# having to write any files. By overloading the "instance" method we can +# make whatever we want. +# ------------------------------------------------------------------------------ +class PackedSceneDouble: + extends PackedScene + var _script = null + var _scene = null + + func set_script_obj(obj): + _script = obj + + func instance(edit_state = 0): + var inst = _scene.instance(edit_state) + if _script != null: + inst.set_script(_script) + return inst + + func load_scene(path): + _scene = load(path) + + +# ------------------------------------------------------------------------------ +# START Doubler +# ------------------------------------------------------------------------------ +var _utils = load("res://addons/gut/utils.gd").get_instance() + +var _ignored_methods = _utils.OneToMany.new() +var _stubber = _utils.Stubber.new() +var _lgr = _utils.get_logger() +var _method_maker = _utils.MethodMaker.new() + +var _output_dir = "user://gut_temp_directory" +var _double_count = 0 # used in making files names unique +var _spy = null +var _gut = null +var _strategy = null +var _base_script_text = _utils.get_file_as_text( + "res://addons/gut/double_templates/script_template.txt" +) +var _make_files = false +# used by tests for debugging purposes. +var _print_source = false + + +func _init(strategy = _utils.DOUBLE_STRATEGY.PARTIAL): + set_logger(_utils.get_logger()) + _strategy = strategy + + +# ############### +# Private +# ############### +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(obj_info, method_name): + if _utils.non_super_methods.has(method_name): + return + + var path = obj_info.get_path() + if obj_info.is_singleton(): + path = obj_info.get_singleton_name() + elif obj_info.scene_path != null: + path = obj_info.scene_path + + var params = _utils.StubParams.new(path, method_name, obj_info.get_subpath()) + params.to_call_super() + _stubber.add_stub(params) + + +func _get_base_script_text(obj_info, override_path): + var path = obj_info.get_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 values = { + # Top sections + "extends": obj_info.get_extends_text(), + "constants": obj_info.get_constants_text(), + "properties": obj_info.get_properties_text(), + # metadata values + "path": path, + "subpath": obj_info.get_subpath(), + "stubber_id": stubber_id, + "spy_id": spy_id, + "gut_id": gut_id, + "singleton_name": _utils.nvl(obj_info.get_singleton_name(), ""), + "is_partial": str(obj_info.make_partial_double).to_lower() + } + + return _base_script_text.format(values) + + +func _write_file(obj_info, dest_path, override_path = null): + var base_script = _get_base_script_text(obj_info, override_path) + var script_methods = _get_methods(obj_info) + var super_name = "" + var path = "" + + if obj_info.is_singleton(): + super_name = obj_info.get_singleton_name() + else: + path = obj_info.get_path() + + var f = FileOrString.new() + f._do_file = _make_files + var f_result = f.open(dest_path, f.WRITE) + + if f_result != OK: + _lgr.error(str("Error creating file ", dest_path)) + _lgr.error(str("Could not create double for :", obj_info.to_s())) + return + + f.store_string(base_script) + + for i in range(script_methods.local_methods.size()): + f.store_string(_get_func_text(script_methods.local_methods[i], path, super_name)) + + for i in range(script_methods.built_ins.size()): + _stub_to_call_super(obj_info, script_methods.built_ins[i].name) + f.store_string(_get_func_text(script_methods.built_ins[i], path, super_name)) + + f.close() + if _print_source: + print(f.get_contents()) + return f + + +func _double_scene_and_script(scene_info): + var to_return = PackedSceneDouble.new() + to_return.load_scene(scene_info.get_path()) + + var inst = load(scene_info.get_path()).instance() + var script_path = null + if inst.get_script(): + script_path = inst.get_script().get_path() + inst.free() + + if script_path: + var oi = ObjectInfo.new(script_path) + oi.set_method_strategy(scene_info.get_method_strategy()) + oi.make_partial_double = scene_info.make_partial_double + oi.scene_path = scene_info.get_path() + to_return.set_script_obj(_double(oi, scene_info.get_path()).load_it()) + + return to_return + + +func _get_methods(object_info): + var obj = object_info.instantiate() + # any method in the script or super script + var script_methods = ScriptMethods.new() + var methods = obj.get_method_list() + + if !object_info.is_singleton() and !(obj is Reference): + obj.free() + + # first pass is for local methods only + for i in range(methods.size()): + if object_info.is_singleton(): + #print(methods[i].name, " :: ", methods[i].flags, " :: ", methods[i].id) + #print(" ", methods[i]) + + # It appears that the ID for methods upstream from a singleton are + # below 200. Initially it was thought that singleton specific methods + # were above 1000. This was true for Input but not for OS. I've + # changed the condition to be > 200 instead of > 1000. It will take + # some investigation to figure out if this is right, but it works + # for now. Someone either find an issue and open a bug, or this will + # just exist like this. Sorry future me (or someone else). + if methods[i].id > 200 and methods[i].flags in [1, 9]: + script_methods.add_local_method(methods[i]) + + # 65 is a magic number for methods in script, though documentation + # says 64. This picks up local overloads of base class methods too. + # See MethodFlags in @GlobalScope + elif ( + methods[i].flags == 65 + and !_ignored_methods.has(object_info.get_path(), methods[i]["name"]) + ): + script_methods.add_local_method(methods[i]) + + if object_info.get_method_strategy() == _utils.DOUBLE_STRATEGY.FULL: + # second pass is for anything not local + for j in range(methods.size()): + # 65 is a magic number for methods in script, though documentation + # says 64. This picks up local overloads of base class methods too. + if ( + methods[j].flags != 65 + and !_ignored_methods.has(object_info.get_path(), methods[j]["name"]) + ): + script_methods.add_built_in_method(methods[j]) + + return script_methods + + +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, path, super = ""): + var override_count = null + if _stubber != null: + override_count = _stubber.get_parameter_count(path, method_hash.name) + + var text = _method_maker.get_function_text(method_hash, path, override_count, super) + "\n" + + return text + + +# returns the path to write the double file to +func _get_temp_path(object_info): + var file_name = null + var extension = null + + if object_info.is_singleton(): + file_name = str(object_info.get_singleton_instance()) + extension = "gd" + elif object_info.is_native(): + file_name = object_info.get_native_class_name() + extension = "gd" + else: + file_name = object_info.get_path().get_file().get_basename() + extension = object_info.get_path().get_extension() + + if object_info.has_subpath(): + file_name += "__" + object_info.get_subpath().replace("/", "__") + + file_name += str("__dbl", _double_count, "__.", extension) + + var to_return = _output_dir.plus_file(file_name) + return to_return + + +func _double(obj_info, override_path = null): + var temp_path = _get_temp_path(obj_info) + var result = _write_file(obj_info, temp_path, override_path) + _double_count += 1 + return result + + +func _double_script(path, make_partial, strategy): + var oi = ObjectInfo.new(path) + oi.make_partial_double = make_partial + oi.set_method_strategy(strategy) + return _double(oi).load_it() + + +func _double_inner(path, subpath, make_partial, strategy): + var oi = ObjectInfo.new(path, subpath) + oi.set_method_strategy(strategy) + oi.make_partial_double = make_partial + return _double(oi).load_it() + + +func _double_scene(path, make_partial, strategy): + var oi = ObjectInfo.new(path) + oi.set_method_strategy(strategy) + oi.make_partial_double = make_partial + return _double_scene_and_script(oi) + + +func _double_gdnative(native_class, make_partial, strategy): + var oi = ObjectInfo.new(null) + oi.set_native_class(native_class) + oi.set_method_strategy(strategy) + oi.make_partial_double = make_partial + return _double(oi).load_it() + + +func _double_singleton(singleton_name, make_partial, strategy): + var oi = ObjectInfo.new(null) + oi.set_singleton_name(singleton_name) + oi.set_method_strategy(_utils.DOUBLE_STRATEGY.PARTIAL) + oi.make_partial_double = make_partial + return _double(oi).load_it() + + +# ############### +# Public +# ############### +func get_output_dir(): + return _output_dir + + +func set_output_dir(output_dir): + if output_dir != null: + _output_dir = output_dir + if _make_files: + var d = Directory.new() + d.make_dir_recursive(output_dir) + + +func get_spy(): + return _spy + + +func set_spy(spy): + _spy = spy + + +func get_stubber(): + return _stubber + + +func set_stubber(stubber): + _stubber = stubber + + +func get_logger(): + return _lgr + + +func set_logger(logger): + _lgr = logger + _method_maker.set_logger(logger) + + +func get_strategy(): + return _strategy + + +func set_strategy(strategy): + _strategy = strategy + + +func get_gut(): + return _gut + + +func set_gut(gut): + _gut = gut + + +func partial_double_scene(path, strategy = _strategy): + return _double_scene(path, true, strategy) + + +# double a scene +func double_scene(path, strategy = _strategy): + return _double_scene(path, false, strategy) + + +# double a script/object +func double(path, strategy = _strategy): + return _double_script(path, false, strategy) + + +func partial_double(path, strategy = _strategy): + return _double_script(path, true, strategy) + + +func partial_double_inner(path, subpath, strategy = _strategy): + return _double_inner(path, subpath, true, strategy) + + +# double an inner class in a script +func double_inner(path, subpath, strategy = _strategy): + return _double_inner(path, subpath, false, strategy) + + +# must always use FULL strategy since this is a native class and you won't get +# any methods if you don't use FULL +func double_gdnative(native_class): + return _double_gdnative(native_class, false, _utils.DOUBLE_STRATEGY.FULL) + + +# must always use FULL strategy since this is a native class and you won't get +# any methods if you don't use FULL +func partial_double_gdnative(native_class): + return _double_gdnative(native_class, true, _utils.DOUBLE_STRATEGY.FULL) + + +func double_singleton(name): + return _double_singleton(name, false, _utils.DOUBLE_STRATEGY.PARTIAL) + + +func partial_double_singleton(name): + return _double_singleton(name, true, _utils.DOUBLE_STRATEGY.PARTIAL) + + +func clear_output_directory(): + if !_make_files: + return false + + var did = false + if _output_dir.find("user://") == 0: + var d = Directory.new() + var result = d.open(_output_dir) + # BIG GOTCHA HERE. If it cannot open the dir w/ erro 31, then the + # directory becomes res:// and things go on normally and gut clears out + # out res:// which is SUPER BAD. + if result == OK: + d.list_dir_begin(true) + var f = d.get_next() + while f != "": + d.remove(f) + f = d.get_next() + did = true + return did + + +func delete_output_directory(): + var did = clear_output_directory() + if did: + var d = Directory.new() + d.remove(_output_dir) + + +func add_ignored_method(path, method_name): + _ignored_methods.add(path, method_name) + + +func get_ignored_methods(): + return _ignored_methods + + +func get_make_files(): + return _make_files + + +func set_make_files(make_files): + _make_files = make_files + set_output_dir(_output_dir) + + +func get_method_maker(): + return _method_maker diff --git a/addons/gut/get_native_script.gd b/addons/gut/get_native_script.gd new file mode 100644 index 0000000..7055fc3 --- /dev/null +++ b/addons/gut/get_native_script.gd @@ -0,0 +1,6 @@ +# Since NativeScript does not exist if GDNative is not included in the build +# of Godot this script is conditionally loaded only when NativeScript exists. +# You can then get a reference to NativeScript for use in `is` checks by calling +# get_it. +static func get_it(): + return NativeScript diff --git a/addons/gut/gui/GutRunner.gd b/addons/gut/gui/GutRunner.gd new file mode 100644 index 0000000..6bc272e --- /dev/null +++ b/addons/gut/gui/GutRunner.gd @@ -0,0 +1,103 @@ +extends Node2D + +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") + +const RUNNER_JSON_PATH = "res://.gut_editor_config.json" +const RESULT_FILE = "user://.gut_editor.bbcode" +const RESULT_JSON = "user://.gut_editor.json" + +var _gut_config = null +var _gut = null +var _wrote_results = false +# Flag for when this is being used at the command line. Otherwise it is +# assumed this is being used by the panel and being launched with +# play_custom_scene +var _cmdln_mode = false + +onready var _gut_layer = $GutLayer + + +func _ready(): + if _gut_config == null: + _gut_config = GutConfig.new() + _gut_config.load_options(RUNNER_JSON_PATH) + + # The command line will call run_tests on its own. When used from the panel + # we have to kick off the tests ourselves b/c there's no way I know of to + # interact with the scene that was run via play_custom_scene. + if !_cmdln_mode: + call_deferred("run_tests") + + +func run_tests(): + if _gut == null: + _gut = Gut.new() + + _gut.set_add_children_to(self) + if _gut_config.options.gut_on_top: + _gut_layer.add_child(_gut) + else: + add_child(_gut) + + if !_cmdln_mode: + _gut.connect( + "tests_finished", + self, + "_on_tests_finished", + [_gut_config.options.should_exit, _gut_config.options.should_exit_on_success] + ) + + _gut_config.config_gut(_gut) + if _gut_config.options.gut_on_top: + _gut.get_gui().goto_bottom_right_corner() + + var run_rest_of_scripts = _gut_config.options.unit_test_name == "" + _gut.test_scripts(run_rest_of_scripts) + + +func _write_results(): + # bbcode_text appears to be empty. I'm not 100% sure why. Until that is + # figured out we have to just get the text which stinks. + var content = _gut.get_gui().get_text_box().text + + var f = File.new() + var result = f.open(RESULT_FILE, f.WRITE) + if result == OK: + f.store_string(content) + f.close() + else: + print("ERROR Could not save bbcode, result = ", result) + + var exporter = ResultExporter.new() + var f_result = exporter.write_summary_file(_gut, RESULT_JSON) + _wrote_results = true + + +func _exit_tree(): + if !_wrote_results and !_cmdln_mode: + _write_results() + + +func _on_tests_finished(should_exit, should_exit_on_success): + _write_results() + + if should_exit: + get_tree().quit() + elif should_exit_on_success and _gut.get_fail_count() == 0: + get_tree().quit() + + +func get_gut(): + if _gut == null: + _gut = Gut.new() + return _gut + + +func set_gut_config(which): + _gut_config = which + + +func set_cmdln_mode(is_it): + _cmdln_mode = is_it diff --git a/addons/gut/gui/GutRunner.tscn b/addons/gut/gui/GutRunner.tscn new file mode 100644 index 0000000..077e411 --- /dev/null +++ b/addons/gut/gui/GutRunner.tscn @@ -0,0 +1,9 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://addons/gut/gui/GutRunner.gd" type="Script" id=1] + +[node name="GutRunner" type="Node2D"] +script = ExtResource( 1 ) + +[node name="GutLayer" type="CanvasLayer" parent="."] +layer = 128 diff --git a/addons/gut/gut.gd b/addons/gut/gut.gd new file mode 100644 index 0000000..0bce8be --- /dev/null +++ b/addons/gut/gut.gd @@ -0,0 +1,1812 @@ +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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 the readme at https://github.com/bitwes/Gut/blob/master/README.md for usage details. +# You should also check out the github wiki at: https://github.com/bitwes/Gut/wiki +# ############################################################################## +extends Control + +# -- Settings -- +var _select_script = "" +var _tests_like = "" +var _inner_class_name = "" +var _should_maximize = false setget set_should_maximize, get_should_maximize +var _log_level = 1 setget set_log_level, get_log_level +var _disable_strict_datatype_checks = false setget disable_strict_datatype_checks, is_strict_datatype_checks_disabled +var _test_prefix = "test_" +var _file_prefix = "test_" +var _file_extension = ".gd" +var _inner_class_prefix = "Test" +var _temp_directory = "user://gut_temp_directory" +var _export_path = "" setget set_export_path, get_export_path +var _include_subdirectories = false setget set_include_subdirectories, get_include_subdirectories +var _double_strategy = 1 setget set_double_strategy, get_double_strategy +var _pre_run_script = "" setget set_pre_run_script, get_pre_run_script +var _post_run_script = "" setget set_post_run_script, get_post_run_script +var _color_output = false setget set_color_output, get_color_output +var _junit_xml_file = "" setget set_junit_xml_file, get_junit_xml_file +var _junit_xml_timestamp = false setget set_junit_xml_timestamp, get_junit_xml_timestamp +var _add_children_to = self setget set_add_children_to, get_add_children_to +# -- End Settings -- + +# ########################### +# Other Vars +# ########################### +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" + +var _utils = load("res://addons/gut/utils.gd").get_instance() +var _lgr = _utils.get_logger() +var _strutils = _utils.Strutils.new() +# Used to prevent multiple messages for deprecated setup/teardown messages +var _deprecated_tracker = _utils.ThingCounter.new() + +# The instance that is created from _pre_run_script. Accessible from +# get_pre_run_script_instance. +var _pre_run_script_instance = null +var _post_run_script_instance = null # This is not used except in tests. + +var _script_name = null +var _test_collector = _utils.TestCollector.new() + +# The instanced scripts. This is populated as the scripts are run. +var _test_script_objects = [] + +var _waiting = false +var _done = false +var _is_running = false + +var _current_test = null +var _log_text = "" + +var _pause_before_teardown = false +# when true _pause_before_teardown will be ignored. useful +# when batch processing and you don't want to watch. +var _ignore_pause_before_teardown = false +var _wait_timer = Timer.new() + +var _yield_between = { + should = false, timer = Timer.new(), after_x_tests = 5, tests_since_last_yield = 0 +} + +var _was_yield_method_called = false +# used when yielding to gut instead of some other +# signal. Start with set_yield_time() +var _yield_timer = Timer.new() +var _yield_frames = 0 + +var _unit_test_name = "" +var _new_summary = null + +var _yielding_to = {obj = null, signal_name = ""} + +var _stubber = _utils.Stubber.new() +var _doubler = _utils.Doubler.new() +var _spy = _utils.Spy.new() +var _gui = null +var _orphan_counter = _utils.OrphanCounter.new() +var _autofree = _utils.AutoFree.new() + +# This is populated by test.gd each time a paramterized test is encountered +# for the first time. +var _parameter_handler = null + +# 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. +var _cancel_import = false + +# Used for proper assert tracking and printing during before_all +var _before_all_test_obj = load("res://addons/gut/test_collector.gd").Test.new() +# Used for proper assert tracking and printing during after_all +var _after_all_test_obj = load("res://addons/gut/test_collector.gd").Test.new() + +const SIGNAL_TESTS_FINISHED = "tests_finished" +const SIGNAL_STOP_YIELD_BEFORE_TEARDOWN = "stop_yield_before_teardown" + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +var _should_print_versions = true # used to cut down on output in tests. + + +func _init(): + _before_all_test_obj.name = "before_all" + _after_all_test_obj.name = "after_all" + # When running tests for GUT itself, _utils has been setup to always return + # a new logger so this does not set the gut instance on the base logger + # when creating test instances of GUT. + _lgr.set_gut(self) + + add_user_signal(SIGNAL_TESTS_FINISHED) + add_user_signal("test_finished") + add_user_signal(SIGNAL_STOP_YIELD_BEFORE_TEARDOWN) + add_user_signal("timeout") + + _doubler.set_output_dir(_temp_directory) + _doubler.set_stubber(_stubber) + _doubler.set_spy(_spy) + _doubler.set_gut(self) + + # TODO remove these, universal logger should fix this. + _doubler.set_logger(_lgr) + _spy.set_logger(_lgr) + _stubber.set_logger(_lgr) + _test_collector.set_logger(_lgr) + + _gui = load("res://addons/gut/GutScene.tscn").instance() + + +func _physics_process(delta): + if _yield_frames > 0: + _yield_frames -= 1 + + if _yield_frames <= 0: + emit_signal("timeout") + + +# ------------------------------------------------------------------------------ +# Initialize controls +# ------------------------------------------------------------------------------ +func _ready(): + if !_utils.is_version_ok(): + _print_versions() + push_error(_utils.get_bad_version_text()) + print("Error: ", _utils.get_bad_version_text()) + get_tree().quit() + return + + if _should_print_versions: + _lgr.info(str("using [", OS.get_user_data_dir(), "] for temporary output.")) + + set_process_input(true) + + add_child(_wait_timer) + _wait_timer.set_wait_time(1) + _wait_timer.set_one_shot(true) + + add_child(_yield_between.timer) + _wait_timer.set_one_shot(true) + + add_child(_yield_timer) + _yield_timer.set_one_shot(true) + _yield_timer.connect("timeout", self, "_yielding_callback") + + _setup_gui() + + if _select_script != null: + select_script(_select_script) + + if _tests_like != null: + set_unit_test_name(_tests_like) + + if _should_maximize: + # GUI checks for is_in_tree will not pass yet. + call_deferred("maximize") + + # hide the panel that IS gut so that only the GUI is seen + self.self_modulate = Color(1, 1, 1, 0) + show() + _print_versions() + + +# ------------------------------------------------------------------------------ +# Runs right before free is called. Can't override `free`. +# ------------------------------------------------------------------------------ +func _notification(what): + if what == NOTIFICATION_PREDELETE: + for test_script in _test_script_objects: + if is_instance_valid(test_script): + test_script.free() + + _test_script_objects = [] + + if is_instance_valid(_gui): + _gui.free() + + +func _print_versions(send_all = true): + if !_should_print_versions: + return + + var info = _utils.get_version_text() + + if send_all: + p(info) + else: + var printer = _lgr.get_printer("gui") + printer.send(info + "\n") + + +# ############################################################################## +# +# GUI Events and setup +# +# ############################################################################## +func _setup_gui(): + # This is how we get the size of the control to translate to the gui when + # the scene is run. This is also another reason why the min_rect_size + # must match between both gut and the gui. + _gui.rect_size = self.rect_size + add_child(_gui) + _gui.set_anchor(MARGIN_RIGHT, ANCHOR_END) + _gui.set_anchor(MARGIN_BOTTOM, ANCHOR_END) + _gui.connect("run_single_script", self, "_on_run_one") + _gui.connect("run_script", self, "_on_new_gui_run_script") + _gui.connect("end_pause", self, "_on_new_gui_end_pause") + _gui.connect("ignore_pause", self, "_on_new_gui_ignore_pause") + _gui.connect("log_level_changed", self, "_on_log_level_changed") + var _foo = connect("tests_finished", _gui, "end_run") + + +func _add_scripts_to_gui(): + var scripts = [] + for i in range(_test_collector.scripts.size()): + var s = _test_collector.scripts[i] + var txt = "" + if s.has_inner_class(): + txt = str(" - ", s.inner_class_name, " (", s.tests.size(), ")") + else: + txt = str(s.get_full_name(), " (", s.tests.size(), ")") + scripts.append(txt) + _gui.set_scripts(scripts) + + +func _on_run_one(index): + clear_text() + var indexes = [index] + if !_test_collector.scripts[index].has_inner_class(): + indexes = _get_indexes_matching_path(_test_collector.scripts[index].path) + _test_the_scripts(indexes) + + +func _on_new_gui_run_script(index): + var indexes = [] + clear_text() + for i in range(index, _test_collector.scripts.size()): + indexes.append(i) + _test_the_scripts(indexes) + + +func _on_new_gui_end_pause(): + _pause_before_teardown = false + emit_signal(SIGNAL_STOP_YIELD_BEFORE_TEARDOWN) + + +func _on_new_gui_ignore_pause(should): + _ignore_pause_before_teardown = should + + +func _on_log_level_changed(value): + set_log_level(value) + + +##################### +# +# Events +# +##################### + + +# ------------------------------------------------------------------------------ +# Timeout for the built in timer. emits the timeout signal. Start timer +# with set_yield_time() +# +# signal_watcher._on_watched_signal supports up to 9 additional arguments. +# This is the most number of parameters GUT supports on signals. The comment +# on _on_watched_signal explains reasoning. +# ------------------------------------------------------------------------------ +func _yielding_callback( + from_obj = false, + __arg1 = null, + __arg2 = null, + __arg3 = null, + __arg4 = null, + __arg5 = null, + __arg6 = null, + __arg7 = null, + __arg8 = null, + __arg9 = null +): + _lgr.end_yield() + if _yielding_to.obj: + _yielding_to.obj.call_deferred( + "disconnect", _yielding_to.signal_name, self, "_yielding_callback" + ) + _yielding_to.obj = null + _yielding_to.signal_name = "" + + if from_obj: + # we must yield for a little longer after the signal is emitted so that + # the signal can propagate to other objects. This was discovered trying + # to assert that obj/signal_name was emitted. Without this extra delay + # the yield returns and processing finishes before the rest of the + # objects can get the signal. This works b/c the timer will timeout + # and come back into this method but from_obj will be false. + _yield_timer.set_wait_time(.1) + _yield_timer.start() + else: + emit_signal("timeout") + + +# ------------------------------------------------------------------------------ +# completed signal for GDScriptFucntionState returned from a test script that +# has yielded +# ------------------------------------------------------------------------------ +func _on_test_script_yield_completed(): + _waiting = false + + +##################### +# +# 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() > 0: + 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) + + +# ------------------------------------------------------------------------------ +# Convert the _summary dictionary into text +# ------------------------------------------------------------------------------ +func _print_summary(): + _lgr.log("\n\n*** Run Summary ***", _lgr.fmts.yellow) + + _new_summary.log_summary_text(_lgr) + + var logger_text = "" + if _lgr.get_errors().size() > 0: + logger_text += str("\n* ", _lgr.get_errors().size(), " Errors.") + if _lgr.get_warnings().size() > 0: + logger_text += str("\n* ", _lgr.get_warnings().size(), " Warnings.") + if _lgr.get_deprecated().size() > 0: + logger_text += str("\n* ", _lgr.get_deprecated().size(), " Deprecated calls.") + if logger_text != "": + logger_text = "\nWarnings/Errors:" + logger_text + "\n\n" + _lgr.log(logger_text) + + if _new_summary.get_totals().tests > 0: + var fmt = _lgr.fmts.green + var msg = ( + str(_new_summary.get_totals().passing_tests) + + " passed " + + str(_new_summary.get_totals().failing_tests) + + " failed. " + + str("Tests finished in ", _gui.elapsed_time_as_str()) + ) + if _new_summary.get_totals().failing > 0: + fmt = _lgr.fmts.red + elif _new_summary.get_totals().pending > 0: + fmt = _lgr.fmts.yellow + + _lgr.log(msg, fmt) + else: + _lgr.log("No tests ran", _lgr.fmts.red) + + +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 + + var f = File.new() + if f.file_exists(path): + var inst = load(path).new() + if inst and inst is _utils.HookScript: + 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 +# res://addons/gut/hook_script.gd +# ------------------------------------------------------------------------------ +func _run_hook_script(inst): + if inst != null: + inst.gut = self + 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 = [] + _new_summary = _utils.Summary.new() + + _log_text = "" + + _current_test = null + + _is_running = true + + _yield_between.tests_since_last_yield = 0 + + 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(): + _gui.end_run() + _print_summary() + p("\n") + + # Do not count any of the _test_script_objects since these will be released + # when GUT is released. + _orphan_counter._counters.total += _test_script_objects.size() + if _orphan_counter.get_counter("total") > 0 and _lgr.is_type_enabled("orphan"): + _orphan_counter.print_orphans("total", _lgr) + p("Note: This count does not include GUT objects that will be freed upon exit.") + p(" It also does not include any orphans created by global scripts") + p(" loaded before tests were ran.") + p(str("Total orphans = ", _orphan_counter.orphan_count())) + + if !_utils.is_null_or_empty(_select_script): + p('Ran Scripts matching "' + _select_script + '"') + if !_utils.is_null_or_empty(_unit_test_name): + p('Ran Tests matching "' + _unit_test_name + '"') + if !_utils.is_null_or_empty(_inner_class_name): + p('Ran Inner Classes matching "' + _inner_class_name + '"') + + # For some reason the text edit control isn't scrolling to the bottom after + # the summary is printed. As a workaround, yield for a short time and + # then move the cursor. I found this workaround through trial and error. + _yield_between.timer.set_wait_time(0.1) + _yield_between.timer.start() + yield(_yield_between.timer, "timeout") + _gui.scroll_to_bottom() + + _is_running = false + update() + _run_hook_script(_post_run_script_instance) + _export_results() + emit_signal(SIGNAL_TESTS_FINISHED) + + if _utils.should_display_latest_version: + p("") + p(str("GUT version ", _utils.latest_version, " is now available.")) + + _gui.set_title("Finished.") + _gui.compact_mode(false) + + +# ------------------------------------------------------------------------------ +# Add additional export types here. +# ------------------------------------------------------------------------------ +func _export_results(): + if _junit_xml_file != "": + _export_junit_xml() + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _export_junit_xml(): + var exporter = _utils.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("_", OS.get_unix_time(), ext)) + + var f_result = exporter.write_file(self, output_file) + if f_result == OK: + p(str("Results saved to ", output_file)) + + +# ------------------------------------------------------------------------------ +# Checks the passed in thing to see if it is a "function state" object that gets +# returned when a function yields. +# ------------------------------------------------------------------------------ +func _is_function_state(script_result): + return ( + script_result != null + and typeof(script_result) == TYPE_OBJECT + and script_result is GDScriptFunctionState + and script_result.is_valid() + ) + + +# ------------------------------------------------------------------------------ +# Print out the heading for a new script +# ------------------------------------------------------------------------------ +func _print_script_heading(script): + if _does_class_name_match(_inner_class_name, script.inner_class_name): + var fmt = _lgr.fmts.underline + var divider = "-----------------------------------------" + + var text = "" + if script.inner_class_name == null: + text = script.path + else: + text = script.path + "." + script.inner_class_name + _lgr.log("\n\n" + text, fmt) + + +# ------------------------------------------------------------------------------ +# Just gets more logic out of _test_the_scripts. Decides if we should yield after +# this test based on flags and counters. +# ------------------------------------------------------------------------------ +func _should_yield_now(): + var should = ( + _yield_between.should + and _yield_between.tests_since_last_yield == _yield_between.after_x_tests + ) + if should: + _yield_between.tests_since_last_yield = 0 + else: + _yield_between.tests_since_last_yield += 1 + return should + + +# ------------------------------------------------------------------------------ +# 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 script_class_name.findn(the_class_name) != -1) + ) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _setup_script(test_script): + test_script.gut = self + test_script.set_logger(_lgr) + _add_children_to.add_child(test_script) + _test_script_objects.append(test_script) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _do_yield_between(frames = 2): + _yield_frames = frames + return self + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _wait_for_done(result): + var iter_counter = 0 + var print_after = 3 + + # callback method sets waiting to false. + result.connect(COMPLETED, self, "_on_test_script_yield_completed") + if !_was_yield_method_called: + _lgr.log("-- Yield detected, waiting --", _lgr.fmts.yellow) + + _was_yield_method_called = false + _waiting = true + _wait_timer.set_wait_time(0.4) + + var dots = "" + while _waiting: + iter_counter += 1 + _lgr.yield_text("waiting" + dots) + _wait_timer.start() + yield(_wait_timer, "timeout") + dots += "." + if dots.length() > 5: + dots = "" + + _lgr.end_yield() + + +# ------------------------------------------------------------------------------ +# 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 _call_deprecated_script_method(script, method, alt): + if script.has_method(method): + var txt = str(script, "-", method) + if !_deprecated_tracker.has(txt): + # Removing the deprecated line. I think it's still too early to + # start bothering people with this. Left everything here though + # because I don't want to remember how I did this last time. + _lgr.deprecated( + str("The method ", method, " has been deprecated, use ", alt, " instead.") + ) + _deprecated_tracker.add(txt) + script.call(method) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _get_indexes_matching_script_name(name): + var indexes = [] # empty runs all + for i in range(_test_collector.scripts.size()): + if _test_collector.scripts[i].get_filename().find(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): + var script_result = _run_test(test_script, test_name) + if _current_test.assert_count == 0 and !_current_test.pending: + _lgr.warn("Test did not assert") + + if _is_function_state(script_result): + # _run_tests does _wait_for_done so just wait on it to complete + yield(script_result, COMPLETED) + + 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: + while !_parameter_handler.is_done(): + var cur_assert_count = _current_test.assert_count + script_result = _run_test(test_script, test_name) + if _is_function_state(script_result): + # _run_tests does _wait_for_done so just wait on it to complete + yield(script_result, COMPLETED) + + if _current_test.assert_count == cur_assert_count and !_current_test.pending: + _lgr.warn("Test did not assert") + + _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): + _lgr.log_test_name() + _lgr.set_indent_level(1) + _orphan_counter.add_counter("test") + var script_result = null + + _call_deprecated_script_method(script_inst, "setup", "before_each") + var before_each_result = script_inst.before_each() + if _is_function_state(before_each_result): + yield(_wait_for_done(before_each_result), COMPLETED) + + # When the script yields it will return a GDScriptFunctionState object + script_result = script_inst.call(test_name) + var test_summary = _new_summary.add_test(test_name) + + # Cannot detect future yields since we never tell the method to resume. If + # there was some way to tell the method to resume we could use what comes + # back from that to detect additional yields. I don't think this is + # possible since we only know what the yield was for except when yield_for + # and yield_to are used. + if _is_function_state(script_result): + yield(_wait_for_done(script_result), COMPLETED) + + # if the test called pause_before_teardown then yield until + # the continue button is pressed. + if _pause_before_teardown and !_ignore_pause_before_teardown: + _gui.pause() + yield(_wait_for_continue_button(), SIGNAL_STOP_YIELD_BEFORE_TEARDOWN) + + script_inst.clear_signal_watcher() + + # call each post-each-test method until teardown is removed. + _call_deprecated_script_method(script_inst, "teardown", "after_each") + var after_each_result = script_inst.after_each() + if _is_function_state(after_each_result): + yield(_wait_for_done(after_each_result), COMPLETED) + + # 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 = _autofree.get_queue_free_count() + _autofree.free_all() + if aqf_count > 0: + yield(_do_yield_between(), "timeout") + + test_summary.orphans = _orphan_counter.get_counter("test") + if _log_level > 0: + _orphan_counter.print_orphans("test", _lgr) + + _doubler.get_ignored_methods().clear() + + +# ------------------------------------------------------------------------------ +# 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 pre-all-tests methods until prerun_setup is removed +# ------------------------------------------------------------------------------ +func _call_before_all(test_script): + _current_test = _before_all_test_obj + _current_test.has_printed_name = false + _lgr.inc_indent() + + # Next 3 lines can be removed when prerun_setup removed. + _current_test.name = "prerun_setup" + _call_deprecated_script_method(test_script, "prerun_setup", "before_all") + _current_test.name = "before_all" + + var result = test_script.before_all() + if _is_function_state(result): + yield(_wait_for_done(result), COMPLETED) + + _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): + _current_test = _after_all_test_obj + _current_test.has_printed_name = false + _lgr.inc_indent() + + # Next 3 lines can be removed when postrun_teardown removed. + _current_test.name = "postrun_teardown" + _call_deprecated_script_method(test_script, "postrun_teardown", "after_all") + _current_test.name = "after_all" + + var result = test_script.after_all() + if _is_function_state(result): + yield(_wait_for_done(result), COMPLETED) + + _lgr.dec_indent() + _current_test = null + + +# ------------------------------------------------------------------------------ +# Run all tests in a script. This is the core logic for running tests. +# ------------------------------------------------------------------------------ +func _test_the_scripts(indexes = []): + _orphan_counter.add_counter("total") + + _print_versions(false) + var is_valid = _init_run() + if !is_valid: + _lgr.error("Something went wrong and the run was aborted.") + return + + _run_hook_script(_pre_run_script_instance) + if _pre_run_script_instance != null and _pre_run_script_instance.should_abort(): + _lgr.error("pre-run abort") + emit_signal(SIGNAL_TESTS_FINISHED) + return + + _gui.run_mode() + + 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 + + _gui.set_progress_script_max(indexes_to_run.size()) # New way + _gui.set_progress_script_value(0) + + if _doubler.get_strategy() == _utils.DOUBLE_STRATEGY.FULL: + _lgr.info( + "Using Double Strategy FULL as default strategy. Keep an eye out for weirdness, this is still experimental." + ) + + # loop through scripts + for test_indexes in range(indexes_to_run.size()): + var the_script = _test_collector.scripts[indexes_to_run[test_indexes]] + _orphan_counter.add_counter("script") + + if the_script.tests.size() > 0: + _gui.set_script_path(the_script.get_full_name()) + _lgr.set_indent_level(0) + _print_script_heading(the_script) + _new_summary.add_script(the_script.get_full_name()) + + var test_script = the_script.get_new() + var script_result = null + _setup_script(test_script) + _doubler.set_strategy(_double_strategy) + + # yield between test scripts so things paint + if _yield_between.should: + yield(_do_yield_between(), "timeout") + + # !!! + # 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, the_script.inner_class_name): + the_script.tests = [] + else: + var before_all_result = _call_before_all(test_script) + if _is_function_state(before_all_result): + # _call_before_all calls _wait for done, just wait for that to finish + yield(before_all_result, COMPLETED) + + _gui.set_progress_test_max(the_script.tests.size()) # New way + + # Each test in the script + for i in range(the_script.tests.size()): + _stubber.clear() + _spy.clear() + _doubler.clear_output_directory() + _current_test = the_script.tests[i] + script_result = null + + if ( + (_unit_test_name != "" and _current_test.name.find(_unit_test_name) > -1) + or (_unit_test_name == "") + ): + # yield so things paint + if _should_yield_now(): + yield(_do_yield_between(), "timeout") + + 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: + script_result = _run_parameterized_test(test_script, _current_test.name) + else: + script_result = _run_test(test_script, _current_test.name) + + if _is_function_state(script_result): + # _run_test calls _wait for done, just wait for that to finish + yield(script_result, COMPLETED) + + if _current_test.assert_count == 0 and !_current_test.pending: + _lgr.warn("Test did not assert") + _current_test.has_printed_name = false + _gui.set_progress_test_value(i + 1) + emit_signal("test_finished") + + _current_test = null + _lgr.dec_indent() + _orphan_counter.print_orphans("script", _lgr) + + if _does_class_name_match(_inner_class_name, the_script.inner_class_name): + var after_all_result = _call_after_all(test_script) + if _is_function_state(after_all_result): + # _call_after_all calls _wait for done, just wait for that to finish + yield(after_all_result, COMPLETED) + + _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 child. + _add_children_to.remove_child(test_script) + + _lgr.set_indent_level(0) + if test_script.get_assert_count() > 0: + var script_sum = str( + test_script.get_pass_count(), "/", test_script.get_assert_count(), " passed." + ) + _lgr.log(script_sum, _lgr.fmts.bold) + + _gui.set_progress_script_value(test_indexes + 1) # new way + # END TEST SCRIPT LOOP + + _lgr.set_indent_level(0) + _end_run() + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _pass(text = ""): + _gui.add_passing() # increments counters + if _current_test: + _current_test.assert_count += 1 + _new_summary.add_pass(_current_test.name, text) + else: + if _new_summary != null: # b/c of tests. + _new_summary.add_pass("script level", text) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _fail(text = ""): + _gui.add_failing() # increments counters + if _current_test != null: + var line_text = " at line " + str(_extract_line_number(_current_test)) + p(line_text, LOG_LEVEL_FAIL_ONLY) + # format for summary + line_text = "\n " + line_text + var call_count_text = "" + if _parameter_handler != null: + call_count_text = str("(call #", _parameter_handler.get_call_count(), ") ") + _new_summary.add_fail(_current_test.name, call_count_text + text + line_text) + _current_test.passed = false + _current_test.assert_count += 1 + else: + if _new_summary != null: # b/c of tests. + _new_summary.add_fail("script level", 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 + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _pending(text = ""): + if _current_test: + _current_test.pending = true + _new_summary.add_pending(_current_test.name, text) + + +# ------------------------------------------------------------------------------ +# Gets all the files in a directory and all subdirectories if get_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 = Directory.new() + d.open(path) + # true parameter tells list_dir_begin not to include "." and ".." directories. + d.list_dir_begin(true) + + # 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. + var fs_item = d.get_next() + var full_path = "" + while fs_item != "": + full_path = path.plus_file(fs_item) + + #file_exists returns fasle for directories + if d.file_exists(full_path): + if fs_item.begins_with(prefix) and fs_item.ends_with(suffix): + files.append(full_path) + elif get_include_subdirectories() and d.dir_exists(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 +# +######################### + + +# ------------------------------------------------------------------------------ +# 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. +# +# NOT_USED_ANYMORE was indent level. This was deprecated in 7.0.0. +# ------------------------------------------------------------------------------ +func p(text, level = 0, NOT_USED_ANYMORE = -123): + if NOT_USED_ANYMORE != -123: + _lgr.deprecated( + "gut.p no longer supports the optional 3rd parameter for indent_level parameter." + ) + var str_text = str(text) + + if level <= _utils.nvl(_log_level, 0): + _lgr.log(str_text) + + +################ +# +# RUN TESTS/ADD SCRIPTS +# +################ +func get_minimum_size(): + return Vector2(810, 380) + + +# ------------------------------------------------------------------------------ +# Runs all the scripts that were added using add_script +# ------------------------------------------------------------------------------ +func test_scripts(run_rest = false): + clear_text() + + if _script_name != null and _script_name != "": + var indexes = _get_indexes_matching_script_name(_script_name) + if indexes == []: + _lgr.error("Could not find script matching " + _script_name) + 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 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_scripts_to_gui() + + +# ------------------------------------------------------------------------------ +# 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 = _file_extension): + # 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 d = Directory.new() + if !d.dir_exists(path): + _lgr.error(str("The path [", path, "] does not exist.")) + OS.exit_code = 1 + else: + var files = _get_files(path, prefix, suffix) + for i in range(files.size()): + 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: + p(_test_collector.to_s()) + p("Exported to " + path) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func import_tests(path = _export_path): + if !_utils.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: + p(_test_collector.to_s()) + p("Imported from " + path) + _add_scripts_to_gui() + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +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 +# +################ + + +# ------------------------------------------------------------------------------ +# Maximize test runner window to fit the viewport. +# ------------------------------------------------------------------------------ +func set_should_maximize(should): + _should_maximize = should + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_should_maximize(): + return _should_maximize + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func maximize(): + _gui.maximize() + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func disable_strict_datatype_checks(should): + _disable_strict_datatype_checks = should + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func is_strict_datatype_checks_disabled(): + return _disable_strict_datatype_checks + + +# ------------------------------------------------------------------------------ +# Pauses the test and waits for you to press a confirmation button. Useful when +# you want to watch a test play out onscreen or inspect results. +# ------------------------------------------------------------------------------ +func end_yielded_test(): + _lgr.deprecated("end_yielded_test is no longer necessary, you can remove it.") + + +# ------------------------------------------------------------------------------ +# Clears the text of the text box. This resets all counters. +# ------------------------------------------------------------------------------ +func clear_text(): + _gui.clear_text() + update() + + +# ------------------------------------------------------------------------------ +# Get the number of tests that were ran +# ------------------------------------------------------------------------------ +func get_test_count(): + return _new_summary.get_totals().tests + + +# ------------------------------------------------------------------------------ +# Get the number of assertions that were made +# ------------------------------------------------------------------------------ +func get_assert_count(): + var t = _new_summary.get_totals() + return t.passing + t.failing + + +# ------------------------------------------------------------------------------ +# Get the number of assertions that passed +# ------------------------------------------------------------------------------ +func get_pass_count(): + return _new_summary.get_totals().passing + + +# ------------------------------------------------------------------------------ +# Get the number of assertions that failed +# ------------------------------------------------------------------------------ +func get_fail_count(): + return _new_summary.get_totals().failing + + +# ------------------------------------------------------------------------------ +# Get the number of tests flagged as pending +# ------------------------------------------------------------------------------ +func get_pending_count(): + return _new_summary.get_totals().pending + + +# ------------------------------------------------------------------------------ +# Get the results of all tests ran as text. This string is the same as is +# displayed in the text box, and similar to what is printed to the console. +# ------------------------------------------------------------------------------ +func get_result_text(): + return _log_text + + +# ------------------------------------------------------------------------------ +# 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) + + if !Engine.is_editor_hint(): + _gui.set_log_level(level) + + +# ------------------------------------------------------------------------------ +# Get the current log level. +# ------------------------------------------------------------------------------ +func get_log_level(): + return _log_level + + +# ------------------------------------------------------------------------------ +# 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 + + +# ------------------------------------------------------------------------------ +# For batch processing purposes, you may want to ignore any calls to +# pause_before_teardown that you forgot to remove. +# ------------------------------------------------------------------------------ +func set_ignore_pause_before_teardown(should_ignore): + _ignore_pause_before_teardown = should_ignore + _gui.set_ignore_pause(should_ignore) + + +func get_ignore_pause_before_teardown(): + return _ignore_pause_before_teardown + + +# ------------------------------------------------------------------------------ +# Set to true so that painting of the screen will occur between tests. Allows you +# to see the output as tests occur. Especially useful with long running tests that +# make it appear as though it has humg. +# +# NOTE: not compatible with 1.0 so this is disabled by default. This will +# change in future releases. +# ------------------------------------------------------------------------------ +func set_yield_between_tests(should): + _yield_between.should = should + + +func get_yield_between_tests(): + return _yield_between.should + + +# ------------------------------------------------------------------------------ +# Call _process or _fixed_process, if they exist, on obj and all it's children +# and their children and so and so forth. Delta will be passed through to all +# the _process or _fixed_process methods. +# ------------------------------------------------------------------------------ +func simulate(obj, times, delta): + for _i in range(times): + if obj.has_method("_process"): + obj._process(delta) + if obj.has_method("_physics_process"): + obj._physics_process(delta) + + for kid in obj.get_children(): + simulate(kid, 1, delta) + + +# ------------------------------------------------------------------------------ +# Starts an internal timer with a timeout of the passed in time. A 'timeout' +# signal will be sent when the timer ends. Returns itself so that it can be +# used in a call to yield...cutting down on lines of code. +# +# Example, yield to the Gut object for 10 seconds: +# yield(gut.set_yield_time(10), 'timeout') +# ------------------------------------------------------------------------------ +func set_yield_time(time, text = ""): + _yield_timer.set_wait_time(time) + _yield_timer.start() + var msg = "-- Yielding (" + str(time) + "s)" + if text == "": + msg += " --" + else: + msg += ": " + text + " --" + _lgr.log(msg, _lgr.fmts.yellow) + _was_yield_method_called = true + return self + + +# ------------------------------------------------------------------------------ +# Sets a counter that is decremented each time _process is called. When the +# counter reaches 0 the 'timeout' signal is emitted. +# +# This actually results in waiting N+1 frames since that appears to be what is +# required for _process in test.gd scripts to count N frames. +# ------------------------------------------------------------------------------ +func set_yield_frames(frames, text = ""): + var msg = "-- Yielding (" + str(frames) + " frames)" + if text == "": + msg += " --" + else: + msg += ": " + text + " --" + _lgr.log(msg, _lgr.fmts.yellow) + + _was_yield_method_called = true + _yield_frames = max(frames + 1, 1) + return self + + +# ------------------------------------------------------------------------------ +# This method handles yielding to a signal from an object or a maximum +# number of seconds, whichever comes first. +# ------------------------------------------------------------------------------ +func set_yield_signal_or_time(obj, signal_name, max_wait, text = ""): + obj.connect(signal_name, self, "_yielding_callback", [true]) + _yielding_to.obj = obj + _yielding_to.signal_name = signal_name + + _yield_timer.set_wait_time(max_wait) + _yield_timer.start() + _was_yield_method_called = true + _lgr.log( + str('-- Yielding to signal "', signal_name, '" or for ', max_wait, " seconds -- ", text), + _lgr.fmts.yellow + ) + return self + + +# ------------------------------------------------------------------------------ +# get the specific unit test that should be run +# ------------------------------------------------------------------------------ +func get_unit_test_name(): + return _unit_test_name + + +# ------------------------------------------------------------------------------ +# set the specific unit test that should be run. +# ------------------------------------------------------------------------------ +func set_unit_test_name(test_name): + _unit_test_name = test_name + + +# ------------------------------------------------------------------------------ +# Creates an empty file at the specified path +# ------------------------------------------------------------------------------ +func file_touch(path): + var f = File.new() + f.open(path, f.WRITE) + f.close() + + +# ------------------------------------------------------------------------------ +# deletes the file at the specified path +# ------------------------------------------------------------------------------ +func file_delete(path): + var d = Directory.new() + var result = d.open(path.get_base_dir()) + if result == OK: + d.remove(path) + + +# ------------------------------------------------------------------------------ +# Checks to see if the passed in file has any data in it. +# ------------------------------------------------------------------------------ +func is_file_empty(path): + var f = File.new() + f.open(path, f.READ) + var empty = f.get_len() == 0 + f.close() + return empty + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_file_as_text(path): + return _utils.get_file_as_text(path) + + +# ------------------------------------------------------------------------------ +# deletes all files in a given directory +# ------------------------------------------------------------------------------ +func directory_delete_files(path): + var d = Directory.new() + var result = d.open(path) + + # SHORTCIRCUIT + if result != OK: + return + + # 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 thing = d.get_next() # could be a dir or a file or something else maybe? + var full_path = "" + while thing != "": + full_path = path + "/" + thing + #file_exists returns fasle for directories + if d.file_exists(full_path): + d.remove(full_path) + thing = d.get_next() + d.list_dir_end() + + +# ------------------------------------------------------------------------------ +# Returns the instantiated script object 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 + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_stubber(): + return _stubber + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_doubler(): + return _doubler + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_spy(): + return _spy + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_temp_directory(): + return _temp_directory + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_temp_directory(temp_directory): + _temp_directory = temp_directory + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_inner_class_name(): + return _inner_class_name + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_inner_class_name(inner_class_name): + _inner_class_name = inner_class_name + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_summary(): + return _new_summary + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_double_strategy(): + return _double_strategy + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_double_strategy(double_strategy): + _double_strategy = double_strategy + _doubler.set_strategy(double_strategy) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_include_subdirectories(): + return _include_subdirectories + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_logger(): + return _lgr + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_logger(logger): + _lgr = logger + _lgr.set_gut(self) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_include_subdirectories(include_subdirectories): + _include_subdirectories = include_subdirectories + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_test_collector(): + return _test_collector + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_export_path(): + return _export_path + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_export_path(export_path): + _export_path = export_path + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_version(): + return _utils.version + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_pre_run_script(): + return _pre_run_script + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_pre_run_script(pre_run_script): + _pre_run_script = pre_run_script + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_post_run_script(): + return _post_run_script + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_post_run_script(post_run_script): + _post_run_script = post_run_script + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_pre_run_script_instance(): + return _pre_run_script_instance + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_post_run_script_instance(): + return _post_run_script_instance + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_color_output(): + return _color_output + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_color_output(color_output): + _color_output = color_output + _lgr.disable_formatting(!color_output) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_parameter_handler(): + return _parameter_handler + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_parameter_handler(parameter_handler): + _parameter_handler = parameter_handler + _parameter_handler.set_logger(_lgr) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_gui(): + return _gui + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_orphan_counter(): + return _orphan_counter + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func show_orphans(should): + _lgr.set_type_enabled(_lgr.types.orphan, should) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_autofree(): + return _autofree + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_junit_xml_file(): + return _junit_xml_file + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_junit_xml_file(junit_xml_file): + _junit_xml_file = junit_xml_file + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_junit_xml_timestamp(): + return _junit_xml_timestamp + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_junit_xml_timestamp(junit_xml_timestamp): + _junit_xml_timestamp = junit_xml_timestamp + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func get_add_children_to(): + return _add_children_to + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func set_add_children_to(add_children_to): + _add_children_to = add_children_to diff --git a/addons/gut/gut_cmdln.gd b/addons/gut/gut_cmdln.gd new file mode 100644 index 0000000..b9e84c7 --- /dev/null +++ b/addons/gut/gut_cmdln.gd @@ -0,0 +1,397 @@ +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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 +# ----------- +# Command line interface for the GUT unit testing tool. Allows you to run tests +# from the command line instead of running a scene. Place this script along with +# gut.gd into your scripts directory at the root of your project. Once there you +# can run this script (from the root of your project) using the following command: +# godot -s -d test/gut/gut_cmdln.gd +# +# See the readme for a list of options and examples. You can also use the -gh +# option to get more information about how to use the command line interface. +# ############################################################################## +extends SceneTree + +var Optparse = load("res://addons/gut/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 = null + var cmd_opts = null + var config_opts = null + + 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 _utils = load("res://addons/gut/utils.gd").get_instance() +var _gut_config = load("res://addons/gut/gut_config.gd").new() +# instance of gut +var _tester = null +# array of command line options specified +var _final_opts = [] + + +func setup_options(options, font_names): + var opts = Optparse.new() + opts.set_banner( + ( + "This is the command line interface for the unit testing tool Gut. With this " + + "interface you can run one or more test scripts from the command line. In order " + + "for the Gut options to not clash with any other godot options, each option starts " + + 'with a "g". Also, any option that requires a value will take the form of ' + + '"-g=". There cannot be any spaces between the option, the "=", or ' + + "inside a specified value or godot will think you are trying to run a scene." + ) + ) + opts.add("-gtest", [], "Comma delimited list of full paths to test scripts to run.") + opts.add("-gdir", options.dirs, "Comma delimited list of directories to add tests from.") + opts.add( + "-gprefix", + options.prefix, + 'Prefix used to find tests when specifying -gdir. Default "[default]".' + ) + opts.add( + "-gsuffix", + options.suffix, + 'Suffix used to find tests when specifying -gdir. 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( + "-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 all tests pass.") + opts.add("-glog", options.log_level, "Log level. Default [default]") + opts.add("-gignore_pause", false, "Ignores any calls to gut.pause_before_teardown.") + opts.add( + "-gselect", + "", + ( + "Select a script to run initially. The first script that " + + "was loaded using -gtest or -gdir that contains the specified " + + "string will be executed. You may run others by interacting " + + "with the GUI." + ) + ) + opts.add( + "-gunit_test_name", + "", + ( + "Name of a test to run. Any test that contains the specified " + + "text will be run, all others will be skipped." + ) + ) + opts.add("-gh", false, "Print this help, then quit") + opts.add( + "-gconfig", + "res://.gutconfig.json", + "A config file that contains configuration information. Default is res://.gutconfig.json" + ) + opts.add("-ginner_class", "", "Only run inner classes that contain this string") + opts.add( + "-gopacity", + options.opacity, + "Set opacity of test runner window. Use range 0 - 100. 0 = transparent, 100 = opaque." + ) + opts.add("-gpo", false, "Print option values from all sources and the value used, then quit.") + opts.add("-ginclude_subdirs", false, "Include subdirectories of -gdir.") + opts.add( + "-gdouble_strategy", + "partial", + 'Default strategy to use when doubling. Valid values are [partial, full]. Default "[default]"' + ) + opts.add("-gdisable_colors", false, "Disable command line colors.") + opts.add("-gpre_run_script", "", "pre-run hook script path") + opts.add("-gpost_run_script", "", "post-run hook script path") + opts.add( + "-gprint_gutconfig_sample", + false, + "Print out json that can be used to make a gutconfig file then quit." + ) + + 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( + "-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]" + ) + return opts + + +# Parses options, applying them to the _tester or setting values +# in the options struct. +func extract_command_line_options(from, to): + to.config_file = from.get_value("-gconfig") + to.dirs = from.get_value("-gdir") + to.disable_colors = from.get_value("-gdisable_colors") + to.double_strategy = from.get_value("-gdouble_strategy") + to.ignore_pause = from.get_value("-gignore_pause") + to.include_subdirs = from.get_value("-ginclude_subdirs") + to.inner_class = from.get_value("-ginner_class") + to.log_level = from.get_value("-glog") + to.opacity = from.get_value("-gopacity") + to.post_run_script = from.get_value("-gpost_run_script") + to.pre_run_script = from.get_value("-gpre_run_script") + to.prefix = from.get_value("-gprefix") + to.selected = from.get_value("-gselect") + to.should_exit = from.get_value("-gexit") + to.should_exit_on_success = from.get_value("-gexit_on_success") + to.should_maximize = from.get_value("-gmaximize") + to.compact_mode = from.get_value("-gcompact_mode") + to.hide_orphans = from.get_value("-ghide_orphans") + to.suffix = from.get_value("-gsuffix") + to.tests = from.get_value("-gtest") + to.unit_test_name = from.get_value("-gunit_test_name") + + to.font_size = from.get_value("-gfont_size") + to.font_name = from.get_value("-gfont_name") + to.background_color = from.get_value("-gbackground_color") + to.font_color = from.get_value("-gfont_color") + + to.junit_xml_file = from.get_value("-gjunit_xml_file") + to.junit_xml_timestamp = from.get_value("-gjunit_xml_timestamp") + + +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: command-line, .gutconfig, default).""" + print("\n", header.replace("\n", " "), "\n\n") + var resolved = values + + # remove some options that don't make sense to be in config + resolved.erase("config_file") + resolved.erase("show_help") + + print( + "Here's a config with all the properties set based off of your current command and config." + ) + print(JSON.print(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.print(resolved, " ")) + + +# parse options and run Gut +func _run_gut(): + var opt_resolver = OptionResolver.new() + opt_resolver.set_base_opts(_gut_config.default_options) + + print("\n\n", " --- Gut ---") + var o = setup_options(_gut_config.default_options, _gut_config.valid_fonts) + + var all_options_valid = o.parse() + extract_command_line_options(o, opt_resolver.cmd_opts) + + var load_result = _gut_config.load_options_no_defaults(opt_resolver.get_value("config_file")) + + # SHORTCIRCUIT + if !all_options_valid or load_result == -1: + quit(1) + else: + opt_resolver.config_opts = _gut_config.options + + if o.get_value("-gh"): + print(_utils.get_version_text()) + o.print_help() + quit() + elif o.get_value("-gpo"): + print( + ( + "All command line 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()) + quit() + elif o.get_value("-gprint_gutconfig_sample"): + _print_gutconfigs(opt_resolver.get_resolved_values()) + quit() + else: + _final_opts = opt_resolver.get_resolved_values() + _gut_config.options = _final_opts + + var runner = GutRunner.instance() + runner.set_cmdln_mode(true) + runner.set_gut_config(_gut_config) + + _tester = runner.get_gut() + _tester.connect( + "tests_finished", + self, + "_on_tests_finished", + [_final_opts.should_exit, _final_opts.should_exit_on_success] + ) + + get_root().add_child(runner) + runner.run_tests() + + +# exit if option is set. +func _on_tests_finished(should_exit, should_exit_on_success): + if _final_opts.dirs.size() == 0: + if _tester.get_summary().get_totals().scripts == 0: + var lgr = _tester.get_logger() + lgr.error( + "No directories configured. Add directories with options or a .gutconfig.json file. Use the -gh option for more information." + ) + + if _tester.get_fail_count(): + OS.exit_code = 1 + + # Overwrite the exit code with the post_script + var post_inst = _tester.get_post_run_script_instance() + if post_inst != null and post_inst.get_exit_code() != null: + OS.exit_code = post_inst.get_exit_code() + + if should_exit or (should_exit_on_success and _tester.get_fail_count() == 0): + quit() + else: + print("Tests finished, exit manually") + + +# ------------------------------------------------------------------------------ +# MAIN +# ------------------------------------------------------------------------------ +func _init(): + if !_utils.is_version_ok(): + print("\n\n", _utils.get_version_text()) + push_error(_utils.get_bad_version_text()) + OS.exit_code = 1 + quit() + else: + _run_gut() diff --git a/addons/gut/gut_config.gd b/addons/gut/gut_config.gd new file mode 100644 index 0000000..5d7fef9 --- /dev/null +++ b/addons/gut/gut_config.gd @@ -0,0 +1,172 @@ +var Gut = load("res://addons/gut/gut.gd") + +# Do not want a ref to _utils here due to use by editor plugin. +# _utils needs to be split so that constants and what not do not +# have to rely on the weird singleton thing I made. +enum DOUBLE_STRATEGY { FULL, PARTIAL } + +var valid_fonts = ["AnonymousPro", "CourierPro", "LobsterTwo", "Default"] +var default_options = { + background_color = Color(.15, .15, .15, 1).to_html(), + config_file = "res://.gutconfig.json", + dirs = [], + disable_colors = false, + double_strategy = "partial", + font_color = Color(.8, .8, .8, 1).to_html(), + font_name = "CourierPrime", + 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, + post_run_script = "", + pre_run_script = "", + prefix = "test_", + selected = "", + should_exit = false, + should_exit_on_success = false, + should_maximize = false, + compact_mode = false, + show_help = false, + suffix = ".gd", + tests = [], + unit_test_name = "", + gut_on_top = true, +} + +var default_panel_options = {font_name = "CourierPrime", font_size = 30} + +var options = default_options.duplicate() + + +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): + # SHORTCIRCUIT + var f = File.new() + if !f.file_exists(file_path): + if file_path != "res://.gutconfig.json": + print('ERROR: Config File "', file_path, '" does not exist.') + return -1 + else: + return 1 + + var result = f.open(file_path, f.READ) + if result != OK: + push_error(str("Could not load data ", file_path, " ", result)) + return result + + var json = f.get_as_text() + f.close() + + var results = JSON.parse(json) + # SHORTCIRCUIT + if results.error != OK: + print("\n\n", "!! ERROR parsing file: ", file_path) + print(" at line ", results.error_line, ":") + print(" ", results.error_string) + 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. + for key in into: + if results.result.has(key): + if results.result[key] != null: + into[key] = results.result[key] + + return 1 + + +func write_options(path): + var content = JSON.print(options, " ") + + var f = File.new() + var result = f.open(path, f.WRITE) + if result == OK: + f.store_string(content) + f.close() + return result + + +# Apply all the options specified to _tester. This is where the rubber meets +# the road. +func _apply_options(opts, _tester): + _tester.set_yield_between_tests(true) + _tester.set_modulate(Color(1.0, 1.0, 1.0, min(1.0, float(opts.opacity) / 100))) + _tester.show() + + _tester.set_include_subdirectories(opts.include_subdirs) + + if opts.should_maximize: + _tester.maximize() + + if opts.compact_mode: + _tester.get_gui().compact_mode(true) + + if opts.inner_class != "": + _tester.set_inner_class_name(opts.inner_class) + _tester.set_log_level(opts.log_level) + _tester.set_ignore_pause_before_teardown(opts.ignore_pause) + + for i in range(opts.dirs.size()): + _tester.add_directory(opts.dirs[i], opts.prefix, opts.suffix) + + for i in range(opts.tests.size()): + _tester.add_script(opts.tests[i]) + + if opts.selected != "": + _tester.select_script(opts.selected) + # _run_single = true + + if opts.double_strategy == "full": + _tester.set_double_strategy(DOUBLE_STRATEGY.FULL) + elif opts.double_strategy == "partial": + _tester.set_double_strategy(DOUBLE_STRATEGY.PARTIAL) + + _tester.set_unit_test_name(opts.unit_test_name) + _tester.set_pre_run_script(opts.pre_run_script) + _tester.set_post_run_script(opts.post_run_script) + _tester.set_color_output(!opts.disable_colors) + _tester.show_orphans(!opts.hide_orphans) + _tester.set_junit_xml_file(opts.junit_xml_file) + _tester.set_junit_xml_timestamp(opts.junit_xml_timestamp) + + _tester.get_gui().set_font_size(opts.font_size) + _tester.get_gui().set_font(opts.font_name) + if opts.font_color != null and opts.font_color.is_valid_html_color(): + _tester.get_gui().set_default_font_color(Color(opts.font_color)) + if opts.background_color != null and opts.background_color.is_valid_html_color(): + _tester.get_gui().set_background_color(Color(opts.background_color)) + + return _tester + + +func config_gut(gut): + return _apply_options(options, gut) + + +func load_options(path): + return _load_options_from_config_file(path, options) + + +func load_panel_options(path): + options["panel_options"] = default_panel_options.duplicate() + return _load_options_from_config_file(path, options) + + +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) diff --git a/addons/gut/hook_script.gd b/addons/gut/hook_script.gd new file mode 100644 index 0000000..b02d4d4 --- /dev/null +++ b/addons/gut/hook_script.gd @@ -0,0 +1,47 @@ +class_name GutHookScript +# ------------------------------------------------------------------------------ +# This script is the base for custom scripts to be used in pre and post +# run hooks. +# +# To use, inherit from this script and then implement the run method. +# ------------------------------------------------------------------------------ +var JunitXmlExport = load("res://addons/gut/junit_xml_export.gd") + +# This is the instance of GUT 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. +func run(): + gut.get_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): + _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. post-run script will not be ran. +func abort(): + _should_abort = true + + +func should_abort(): + return _should_abort diff --git a/addons/gut/icon.png b/addons/gut/icon.png new file mode 100644 index 0000000..7c58987 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..3f2dabe --- /dev/null +++ b/addons/gut/icon.png.import @@ -0,0 +1,35 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon.png-91b084043b8aaf2f1c906e7b9fa92969.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gut/icon.png" +dest_files=[ "res://.import/icon.png-91b084043b8aaf2f1c906e7b9fa92969.stex" ] + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +compress/hdr_mode=0 +compress/bptc_ldr=0 +compress/normal_map=0 +flags/repeat=0 +flags/filter=true +flags/mipmaps=false +flags/anisotropic=false +flags/srgb=2 +process/fix_alpha_border=true +process/premult_alpha=false +process/HDR_as_SRGB=false +process/invert_color=false +process/normal_map_invert_y=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/gut/input_factory.gd b/addons/gut/input_factory.gd new file mode 100644 index 0000000..6a7fcee --- /dev/null +++ b/addons/gut/input_factory.gd @@ -0,0 +1,140 @@ +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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 +# ----------- +# ############################################################################## + +# 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()[0] + return key_code + + +static func new_mouse_button_event(position, global_position, pressed, button_index): + 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 + + +static func key_up(which): + var event = InputEventKey.new() + event.scancode = _to_scancode(which) + event.pressed = false + return event + + +static func key_down(which): + var event = InputEventKey.new() + event.scancode = _to_scancode(which) + event.pressed = true + return event + + +static func action_up(which, strength = 1.0): + var event = InputEventAction.new() + event.action = which + event.strength = strength + return event + + +static func action_down(which, strength = 1.0): + var event = InputEventAction.new() + event.action = which + event.strength = strength + event.pressed = true + return event + + +static func mouse_left_button_down(position, global_position = null): + var event = new_mouse_button_event(position, global_position, true, BUTTON_LEFT) + return event + + +static func mouse_left_button_up(position, global_position = null): + var event = new_mouse_button_event(position, global_position, false, BUTTON_LEFT) + return event + + +static func mouse_double_click(position, global_position = null): + var event = new_mouse_button_event(position, global_position, false, BUTTON_LEFT) + event.doubleclick = true + return event + + +static func mouse_right_button_down(position, global_position = null): + var event = new_mouse_button_event(position, global_position, true, BUTTON_RIGHT) + return event + + +static func mouse_right_button_up(position, global_position = null): + var event = new_mouse_button_event(position, global_position, false, BUTTON_RIGHT) + return event + + +static func mouse_motion(position, global_position = null): + var event = InputEventMouseMotion.new() + event.position = position + if global_position != null: + event.global_position = global_position + return event + + +static func mouse_relative_motion(offset, last_motion_event = null, speed = Vector2(0, 0)): + var event = null + if last_motion_event == null: + event = mouse_motion(offset) + event.speed = speed + else: + event = last_motion_event.duplicate() + event.position += offset + event.global_position += offset + event.relative = offset + event.speed = speed + return event diff --git a/addons/gut/input_sender.gd b/addons/gut/input_sender.gd new file mode 100644 index 0000000..a45bbf4 --- /dev/null +++ b/addons/gut/input_sender.gd @@ -0,0 +1,391 @@ +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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(...) +# +# ############################################################################## +#extends "res://addons/gut/input_factory.gd" + +# 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: + emit_signal("event_ready") + + 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 + emit_signal("event_ready") + + 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", self, "_on_time_timeout") + + +# ############################################################################## +# +# ############################################################################## +var _utils = load("res://addons/gut/utils.gd").get_instance() +var InputFactory = load("res://addons/gut/input_factory.gd") + +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 = _utils.get_logger() +var _receivers = [] +var _input_queue = [] +var _next_queue_item = null +# used by mouse_relative_motion. These use this instead of _last_event since +# it is logical to have a series of events happen between mouse motions. +var _last_mouse_motion = null +# used by hold_for and echo. +var _last_event = null + +# indexed by scancode, each entry contains a boolean value indicating the +# last emitted "pressed" value for that scancode. +var _pressed_keys = {} +var _pressed_actions = {} +var _pressed_mouse_buttons = {} + +signal idle + + +func _init(r = null): + if r != null: + add_receiver(r) + + +func _send_event(event): + if event is InputEventKey: + if (event.pressed and !event.echo) and is_key_pressed(event.scancode): + _lgr.warn( + str( + "InputSender: key_down called for ", + event.as_text(), + " when that key is already pressed. ", + INPUT_WARN + ) + ) + _pressed_keys[event.scancode] = 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 + + for r in _receivers: + if r == Input: + Input.parse_input_event(event) + else: + if r.has_method("_input"): + r._input(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 _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 + emit_signal("idle") + else: + _input_queue[0].start() + + +func _add_queue_item(item): + item.connect("event_ready", self, "_on_queue_item_ready", [item]) + _next_queue_item = item + _input_queue.append(item) + Engine.get_main_loop().root.add_child(item) + if _input_queue.size() == 1: + item.start() + + +func add_receiver(obj): + _receivers.append(obj) + + +func get_receivers(): + return _receivers + + +func wait(t): + if typeof(t) == TYPE_STRING: + var suffix = t.substr(t.length() - 1, 1) + var val = float(t.rstrip("s").rstrip("f")) + + if suffix.to_lower() == "s": + wait_secs(val) + elif suffix.to_lower() == "f": + wait_frames(val) + else: + wait_secs(t) + + return self + + +func wait_frames(num_frames): + var item = InputQueueItem.new(0, num_frames) + _add_queue_item(item) + return self + + +func wait_secs(num_secs): + var item = InputQueueItem.new(num_secs, 0) + _add_queue_item(item) + return self + + +# ------------------------------ +# Event methods +# ------------------------------ +func key_up(which): + var event = InputFactory.key_up(which) + _send_or_record_event(event) + return self + + +func key_down(which): + var event = InputFactory.key_down(which) + _send_or_record_event(event) + return self + + +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 + + +func action_up(which, strength = 1.0): + var event = InputFactory.action_up(which, strength) + _send_or_record_event(event) + return self + + +func action_down(which, strength = 1.0): + var event = InputFactory.action_down(which, strength) + _send_or_record_event(event) + return self + + +func mouse_left_button_down(position, global_position = null): + var event = InputFactory.mouse_left_button_down(position, global_position) + _send_or_record_event(event) + return self + + +func mouse_left_button_up(position, global_position = null): + var event = InputFactory.mouse_left_button_up(position, global_position) + _send_or_record_event(event) + return self + + +func mouse_double_click(position, global_position = null): + var event = InputFactory.mouse_double_click(position, global_position) + event.doubleclick = true + _send_or_record_event(event) + return self + + +func mouse_right_button_down(position, global_position = null): + var event = InputFactory.mouse_right_button_down(position, global_position) + _send_or_record_event(event) + return self + + +func mouse_right_button_up(position, global_position = null): + var event = InputFactory.mouse_right_button_up(position, global_position) + _send_or_record_event(event) + return self + + +func mouse_motion(position, global_position = null): + var event = InputFactory.mouse_motion(position, global_position) + _last_mouse_motion = event + _send_or_record_event(event) + return self + + +func mouse_relative_motion(offset, speed = Vector2(0, 0)): + var event = InputFactory.mouse_relative_motion(offset, _last_mouse_motion, speed) + _last_mouse_motion = event + _send_or_record_event(event) + return self + + +func mouse_set_position(position, global_position = null): + _last_mouse_motion = InputFactory.mouse_motion(position, global_position) + return self + + +func send_event(event): + _send_or_record_event(event) + return self + + +func release_all(): + for key in _pressed_keys: + if _pressed_keys[key]: + _send_event(InputFactory.key_up(key)) + _pressed_keys.clear() + + for key in _pressed_actions: + if _pressed_actions[key]: + _send_event(InputFactory.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() + + +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 + + +func clear(): + pass + + _last_event = null + _last_mouse_motion = 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() + + +func is_idle(): + return _input_queue.size() == 0 + + +func is_key_pressed(which): + var event = InputFactory.key_up(which) + return _pressed_keys.has(event.scancode) and _pressed_keys[event.scancode] + + +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] diff --git a/addons/gut/junit_xml_export.gd b/addons/gut/junit_xml_export.gd new file mode 100644 index 0000000..ff52615 --- /dev/null +++ b/addons/gut/junit_xml_export.gd @@ -0,0 +1,95 @@ +# ------------------------------------------------------------------------------ +# Creates an export of a test run in the JUnit XML format. +# ------------------------------------------------------------------------------ +var _utils = load("res://addons/gut/utils.gd").get_instance() + +var _exporter = _utils.ResultExporter.new() + + +func indent(s, ind): + var to_return = ind + s + to_return = to_return.replace("\n", "\n" + ind) + return to_return + + +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('', test.pending[0], "") + to_return += skip_tag + elif test.status == "fail": + var fail_tag = str('', 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 += "= 0 and type_flag < _supported_defaults.size() and [type_flag] != null + + +func _make_stub_default(method, index): + return str('__gut_default_val("', method, '",', index, ")") + + +func _make_arg_array(method_meta, override_size): + var to_return = [] + + var has_unsupported_defaults = false + var dflt_start = method_meta.args.size() - method_meta.default_args.size() + + for i in range(method_meta.args.size()): + var pname = method_meta.args[i].name + var dflt_text = "" + + if i < dflt_start: + dflt_text = _make_stub_default(method_meta.name, i) + else: + var dflt_idx = i - dflt_start + var t = method_meta.args[i]["type"] + if _is_supported_default(t): + # strings are special, they need quotes around the value + if t == TYPE_STRING: + dflt_text = str("'", str(method_meta.default_args[dflt_idx]), "'") + # Colors need the parens but things like Vector2 and Rect2 don't + elif t == TYPE_COLOR: + dflt_text = str( + _supported_defaults[t], "(", str(method_meta.default_args[dflt_idx]), ")" + ) + elif t == TYPE_OBJECT: + if str(method_meta.default_args[dflt_idx]) == "[Object:null]": + dflt_text = str(_supported_defaults[t], "null") + else: + dflt_text = str( + _supported_defaults[t], + str(method_meta.default_args[dflt_idx]).to_lower() + ) + elif t == TYPE_TRANSFORM: + #value will be 4 Vector3 and look like: 1, 0, 0, 0, 1, 0, 0, 0, 1 - 0, 0, 0 + var sections = str(method_meta.default_args[dflt_idx]).split("-") + var vecs = sections[0].split(",") + vecs.append_array(sections[1].split(",")) + var v1 = str("Vector3(", vecs[0], ", ", vecs[1], ", ", vecs[2], ")") + var v2 = str("Vector3(", vecs[3], ", ", vecs[4], ", ", vecs[5], ")") + var v3 = str("Vector3(", vecs[6], ", ", vecs[7], ", ", vecs[8], ")") + var v4 = str("Vector3(", vecs[9], ", ", vecs[10], ", ", vecs[11], ")") + dflt_text = str( + _supported_defaults[t], "(", v1, ", ", v2, ", ", v3, ", ", v4, ")" + ) + elif t == TYPE_TRANSFORM2D: + # value will look like: ((1, 0), (0, 1), (0, 0)) + var vectors = str(method_meta.default_args[dflt_idx]) + vectors = vectors.replace("((", "(") + vectors = vectors.replace("))", ")") + vectors = vectors.replace("(", "Vector2(") + dflt_text = str(_supported_defaults[t], "(", vectors, ")") + elif t == TYPE_RID: + dflt_text = str(_supported_defaults[t], "null") + elif t in [TYPE_REAL_ARRAY, TYPE_INT_ARRAY]: + dflt_text = str(_supported_defaults[t], "()") + + # Everything else puts the prefix (if one is there) form _supported_defaults + # in front. The to_lower is used b/c for some reason the defaults for + # null, true, false are all "Null", "True", "False". + else: + dflt_text = str( + _supported_defaults[t], str(method_meta.default_args[dflt_idx]).to_lower() + ) + else: + _lgr.error( + str( + "Unsupported default param type: ", + method_meta.name, + "-", + method_meta.args[i].name, + " ", + t, + " = ", + method_meta.default_args[dflt_idx] + ) + ) + dflt_text = str("unsupported=", t) + has_unsupported_defaults = true + + # Finally add in the parameter + to_return.append(CallParameters.new(PARAM_PREFIX + pname, dflt_text)) + + # Add in extra parameters from stub settings. + if override_size != null: + for i in range(method_meta.args.size(), override_size): + var pname = str(PARAM_PREFIX, "arg", i) + var dflt_text = _make_stub_default(method_meta.name, i) + to_return.append(CallParameters.new(pname, dflt_text)) + + 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 += str(arg_array[i].p_name, "=", arg_array[i].default) + 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, super_name = ""): + var params = "" + for i in range(args.size()): + params += args[i].p_name + if i != args.size() - 1: + params += ", " + + return str(super_name, ".", method_name, "(", 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 +# ############### + + +# 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, path = null, override_size = null, super_name = ""): + var method_params = "" + var text = null + var result = _make_arg_array(meta, override_size) + 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: + 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, super_name) + } + ) + + return text + + +func get_logger(): + return _lgr + + +func set_logger(logger): + _lgr = logger diff --git a/addons/gut/one_to_many.gd b/addons/gut/one_to_many.gd new file mode 100644 index 0000000..9c0b6df --- /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) and !_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/optparse.gd b/addons/gut/optparse.gd new file mode 100644 index 0000000..cdb28fa --- /dev/null +++ b/addons/gut/optparse.gd @@ -0,0 +1,262 @@ +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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 +# ----------- +# Command line interface for the GUT unit testing tool. Allows you to run tests +# from the command line instead of running a scene. Place this script along with +# gut.gd into your scripts directory at the root of your project. Once there you +# can run this script (from the root of your project) using the following command: +# godot -s -d test/gut/gut_cmdln.gd +# +# See the readme for a list of options and examples. You can also use the -gh +# option to get more information about how to use the command line interface. +# ############################################################################## + +#------------------------------------------------------------------------------- +# Parses the command line arguments supplied into an array that can then be +# examined and parsed based on how the gut options work. +#------------------------------------------------------------------------------- +class CmdLineParser: + var _used_options = [] + # an array of arrays. Each element in this array will contain an option + # name and if that option contains a value then it will have a sedond + # element. For example: + # [[-gselect, test.gd], [-gexit]] + var _opts = [] + + func _init(): + for i in range(OS.get_cmdline_args().size()): + var opt_val = OS.get_cmdline_args()[i].split("=") + _opts.append(opt_val) + + # Parse out multiple comma delimited values from a command line + # option. Values are separated from option name with "=" and + # additional values are comma separated. + func _parse_array_value(full_option): + var value = _parse_option_value(full_option) + var split = value.split(",") + return split + + # Parse out the value of an option. Values are separated from + # the option name with "=" + func _parse_option_value(full_option): + if full_option.size() > 1: + return full_option[1] + else: + return null + + # Search _opts for an element that starts with the option name + # specified. + func find_option(name): + var found = false + var idx = 0 + + while idx < _opts.size() and !found: + if _opts[idx][0] == name: + found = true + else: + idx += 1 + + if found: + return idx + else: + return -1 + + func get_array_value(option): + _used_options.append(option) + var to_return = [] + var opt_loc = find_option(option) + if opt_loc != -1: + to_return = _parse_array_value(_opts[opt_loc]) + _opts.remove(opt_loc) + + return to_return + + # returns the value of an option if it was specified, null otherwise. This + # used to return the default but that became problemnatic when trying to + # punch through the different places where values could be specified. + func get_value(option): + _used_options.append(option) + var to_return = null + var opt_loc = find_option(option) + if opt_loc != -1: + to_return = _parse_option_value(_opts[opt_loc]) + _opts.remove(opt_loc) + + return to_return + + # returns true if it finds the option, false if not. + func was_specified(option): + _used_options.append(option) + return find_option(option) != -1 + + # Returns any unused command line options. I found that only the -s and + # script name come through from godot, all other options that godot uses + # are not sent through OS.get_cmdline_args(). + # + # This is a onetime thing b/c i kill all items in _used_options + func get_unused_options(): + var to_return = [] + for i in range(_opts.size()): + to_return.append(_opts[i][0]) + + var script_option = to_return.find("-s") + if script_option != -1: + to_return.remove(script_option + 1) + to_return.remove(script_option) + + while _used_options.size() > 0: + var index = to_return.find(_used_options[0].split("=")[0]) + if index != -1: + to_return.remove(index) + _used_options.remove(0) + + return to_return + + +#------------------------------------------------------------------------------- +# Simple class to hold a command line option +#------------------------------------------------------------------------------- +class Option: + var value = null + var option_name = "" + var default = null + var description = "" + + func _init(name, default_value, desc = ""): + option_name = name + default = default_value + description = desc + value = null #default_value + + func pad(to_pad, size, pad_with = " "): + var to_return = to_pad + for _i in range(to_pad.length(), size): + to_return += pad_with + + return to_return + + func to_s(min_space = 0): + var subbed_desc = description + if subbed_desc.find("[default]") != -1: + subbed_desc = subbed_desc.replace("[default]", str(default)) + return pad(option_name, min_space) + subbed_desc + + +#------------------------------------------------------------------------------- +# The high level interface between this script and the command line options +# supplied. Uses Option class and CmdLineParser to extract information from +# the command line and make it easily accessible. +#------------------------------------------------------------------------------- +var options = [] +var _opts = [] +var _banner = "" + + +func add(name, default, desc): + options.append(Option.new(name, default, desc)) + + +func get_value(name): + var found = false + var idx = 0 + + while idx < options.size() and !found: + if options[idx].option_name == name: + found = true + else: + idx += 1 + + if found: + return options[idx].value + else: + print("COULD NOT FIND OPTION " + name) + return null + + +func set_banner(banner): + _banner = banner + + +func print_help(): + var longest = 0 + for i in range(options.size()): + if options[i].option_name.length() > longest: + longest = options[i].option_name.length() + + print("---------------------------------------------------------") + print(_banner) + + print("\nOptions\n-------") + for i in range(options.size()): + print(" " + options[i].to_s(longest + 2)) + print("---------------------------------------------------------") + + +func print_options(): + for i in range(options.size()): + print(options[i].option_name + "=" + str(options[i].value)) + + +func parse(): + var parser = CmdLineParser.new() + + for i in range(options.size()): + var t = typeof(options[i].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 parser.was_specified(options[i].option_name): + if t == TYPE_INT: + options[i].value = int(parser.get_value(options[i].option_name)) + elif t == TYPE_STRING: + options[i].value = parser.get_value(options[i].option_name) + elif t == TYPE_ARRAY: + options[i].value = parser.get_array_value(options[i].option_name) + elif t == TYPE_BOOL: + options[i].value = parser.was_specified(options[i].option_name) + elif t == TYPE_NIL: + print(options[i].option_name + " cannot be processed, it has a nil datatype") + else: + print( + ( + options[i].option_name + + " cannot be processed, it has unknown datatype:" + + str(t) + ) + ) + + var unused = parser.get_unused_options() + if unused.size() > 0: + print("Unrecognized options: ", unused) + return false + + return true diff --git a/addons/gut/orphan_counter.gd b/addons/gut/orphan_counter.gd new file mode 100644 index 0000000..d226b8a --- /dev/null +++ b/addons/gut/orphan_counter.gd @@ -0,0 +1,59 @@ +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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 a utility for tracking changes in the orphan count. Each time +# add_counter is called it adds/resets the value in the dictionary to the +# current number of orphans. Each call to get_counter will return the change +# in orphans since add_counter was last called. +# ############################################################################## +var _counters = {} + + +func orphan_count(): + return Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT) + + +func add_counter(name): + _counters[name] = orphan_count() + + +# Returns the number of orphans created since add_counter was last called for +# the name. Returns -1 to avoid blowing up with an invalid name but still +# be somewhat visible that we've done something wrong. +func get_counter(name): + return orphan_count() - _counters[name] if _counters.has(name) else -1 + + +func print_orphans(name, lgr): + var count = get_counter(name) + + if count > 0: + var o = "orphan" + if count > 1: + o = "orphans" + lgr.orphan(str(count, " new ", o, "(", name, ").")) diff --git a/addons/gut/parameter_factory.gd b/addons/gut/parameter_factory.gd new file mode 100644 index 0000000..be10aec --- /dev/null +++ b/addons/gut/parameter_factory.gd @@ -0,0 +1,75 @@ +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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. +# ############################################################################## + +# ------------------------------------------------------------------------------ +# 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: +# create_named_parameters(['a', 'b'], [[1, 2], ['one', 'two']]) returns +# [{a:1, b:2}, {a:'one', b:'two'}] +# +# This allows you to increase readability of your parameterized tests: +# var params = create_named_parameters(['a', 'b'], [[1, 2], ['one', 'two']]) +# func test_foo(p = use_parameters(params)): +# assert_eq(p.a, p.b) +# +# Parameters: +# names: an array of names to be used as keys in the dictionaries +# values: an array of arrays of values. +# ------------------------------------------------------------------------------ +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 + +# Additional Helper Ideas +# * File. IDK what it would look like. csv maybe. +# * Random values within a range? +# * All int values in a range or add an optioanal step. +# * diff --git a/addons/gut/parameter_handler.gd b/addons/gut/parameter_handler.gd new file mode 100644 index 0000000..5934b3d --- /dev/null +++ b/addons/gut/parameter_handler.gd @@ -0,0 +1,44 @@ +var _utils = load("res://addons/gut/utils.gd").get_instance() +var _params = null +var _call_count = 0 +var _logger = null + + +func _init(params = null): + _params = params + _logger = _utils.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/plugin_control.gd b/addons/gut/plugin_control.gd new file mode 100644 index 0000000..3379536 --- /dev/null +++ b/addons/gut/plugin_control.gd @@ -0,0 +1,268 @@ +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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 control that is added via the editor. It exposes GUT settings +# through the editor and delays the creation of the GUT instance until +# Engine.get_main_loop() works as expected. +# ############################################################################## +tool +extends Control + +# ------------------------------------------------------------------------------ +# GUT Settings +# ------------------------------------------------------------------------------ +export(String, "AnonymousPro", "CourierPrime", "LobsterTwo", "Default") var _font_name = "AnonymousPro" +export(int) var _font_size = 20 +export(Color) var _font_color = Color(.8, .8, .8, 1) +export(Color) var _background_color = Color(.15, .15, .15, 1) +# Enable/Disable coloring of output. +export(bool) var _color_output = true +# The full/partial name of a script to select upon startup +export(String) var _select_script = "" +# The full/partial name of a test. All tests that contain the string will be +# run +export(String) var _tests_like = "" +# The full/partial name of an Inner Class to be run. All Inner Classes that +# contain the string will be run. +export(String) var _inner_class_name = "" +# Start running tests when the scene finishes loading +export var _run_on_load = false +# Maximize the GUT control on startup +export var _should_maximize = false +# Print output to the consol as well +export var _should_print_to_console = true +# Display orphan counts at the end of tests/scripts. +export var _show_orphans = true +# The log level. +export(int, "Fail/Errors", "Errors/Warnings/Test Names", "Everything") var _log_level = 1 +# When enabled GUT will yield between tests to give the GUI time to paint. +# Disabling this can make the program appear to hang and can have some +# unwanted consequences with the timing of freeing objects +export var _yield_between_tests = true +# When GUT compares values it first checks the types to prevent runtime errors. +# This behavior can be disabled if desired. This flag was added early in +# development to prevent any breaking changes and will likely be removed in +# the future. +export var _disable_strict_datatype_checks = false +# The prefix used to find test methods. +export var _test_prefix = "test_" +# The prefix used to find test scripts. +export var _file_prefix = "test_" +# The file extension for test scripts (I don't think you can change this and +# everythign work). +export var _file_extension = ".gd" +# The prefix used to find Inner Test Classes. +export var _inner_class_prefix = "Test" +# The directory GUT will use to write any temporary files. This isn't used +# much anymore since there was a change to the double creation implementation. +# This will be removed in a later release. +export(String) var _temp_directory = "user://gut_temp_directory" +# The path and filename for exported test information. +export(String) var _export_path = "" +# When enabled, any directory added will also include its subdirectories when +# GUT looks for test scripts. +export var _include_subdirectories = false +# Allow user to add test directories via editor. This is done with strings +# instead of an array because the interface for editing arrays is really +# cumbersome and complicates testing because arrays set through the editor +# apply to ALL instances. This also allows the user to use the built in +# dialog to pick a directory. +export(String, DIR) var _directory1 = "" +export(String, DIR) var _directory2 = "" +export(String, DIR) var _directory3 = "" +export(String, DIR) var _directory4 = "" +export(String, DIR) var _directory5 = "" +export(String, DIR) var _directory6 = "" +# Must match the types in _utils for double strategy +export(int, "FULL", "PARTIAL") var _double_strategy = 1 +# Path and filename to the script to run before all tests are run. +export(String, FILE) var _pre_run_script = "" +# Path and filename to the script to run after all tests are run. +export(String, FILE) var _post_run_script = "" +# Path to the file that gut will export results to in the junit xml format +export(String, FILE) var _junit_xml_file = "" +# Flag to include a timestamp in the filename of _junit_xml_file +export(bool) var _junit_xml_timestamp = false +# ------------------------------------------------------------------------------ + +# ------------------------------------------------------------------------------ +# Signals +# ------------------------------------------------------------------------------ +# Emitted when all the tests have finished running. +signal tests_finished +# Emitted when GUT is ready to be interacted with, and before any tests are run. +signal gut_ready + +# ------------------------------------------------------------------------------ +# Private stuff. +# ------------------------------------------------------------------------------ +var _gut = null +var _lgr = null +var _cancel_import = false +var _placeholder = null + + +func _init(): + # This min size has to be what the min size of the GutScene's min size is + # but it has to be set here and not inferred i think. + rect_min_size = Vector2(740, 250) + + +func _ready(): + # Must call this deferred so that there is enough time for + # Engine.get_main_loop() is populated and the psuedo singleton utils.gd + # can be setup correctly. + if Engine.editor_hint: + _placeholder = load("res://addons/gut/GutScene.tscn").instance() + call_deferred("add_child", _placeholder) + _placeholder.rect_size = rect_size + else: + call_deferred("_setup_gut") + + connect("resized", self, "_on_resized") + + +func _on_resized(): + if _placeholder != null: + _placeholder.rect_size = rect_size + + +# Templates can be missing if tests are exported and the export config for the +# project does not include '*.txt' files. This check and related flags make +# sure GUT does not blow up and that the error is not lost in all the import +# output that is generated as well as ensuring that no tests are run. +# +# Assumption: This is only a concern when running from the scene since you +# cannot run GUT from the command line in an exported game. +func _check_for_templates(): + var f = File.new() + if !f.file_exists("res://addons/gut/double_templates/function_template.txt"): + _lgr.error( + 'Templates are missing. Make sure you are exporting "*.txt" or "addons/gut/double_templates/*.txt".' + ) + _run_on_load = false + _cancel_import = true + return false + return true + + +func _setup_gut(): + var _utils = load("res://addons/gut/utils.gd").get_instance() + + _lgr = _utils.get_logger() + _gut = load("res://addons/gut/gut.gd").new() + _gut.connect("tests_finished", self, "_on_tests_finished") + + if !_check_for_templates(): + return + + _gut._select_script = _select_script + _gut._tests_like = _tests_like + _gut._inner_class_name = _inner_class_name + + _gut._test_prefix = _test_prefix + _gut._file_prefix = _file_prefix + _gut._file_extension = _file_extension + _gut._inner_class_prefix = _inner_class_prefix + _gut._temp_directory = _temp_directory + + _gut.set_should_maximize(_should_maximize) + _gut.set_yield_between_tests(_yield_between_tests) + _gut.disable_strict_datatype_checks(_disable_strict_datatype_checks) + _gut.set_export_path(_export_path) + _gut.set_include_subdirectories(_include_subdirectories) + _gut.set_double_strategy(_double_strategy) + _gut.set_pre_run_script(_pre_run_script) + _gut.set_post_run_script(_post_run_script) + _gut.set_color_output(_color_output) + _gut.show_orphans(_show_orphans) + _gut.set_junit_xml_file(_junit_xml_file) + _gut.set_junit_xml_timestamp(_junit_xml_timestamp) + + get_parent().add_child(_gut) + + if !_utils.is_version_ok(): + return + + _gut.set_log_level(_log_level) + + _gut.add_directory(_directory1) + _gut.add_directory(_directory2) + _gut.add_directory(_directory3) + _gut.add_directory(_directory4) + _gut.add_directory(_directory5) + _gut.add_directory(_directory6) + + _gut.get_logger().disable_printer("console", !_should_print_to_console) + # When file logging enabled then the log will contain terminal escape + # strings. So when running the scene this is disabled. Also if enabled + # this may cause duplicate entries into the logs. + _gut.get_logger().disable_printer("terminal", true) + + _gut.get_gui().set_font_size(_font_size) + _gut.get_gui().set_font(_font_name) + _gut.get_gui().set_default_font_color(_font_color) + _gut.get_gui().set_background_color(_background_color) + _gut.get_gui().rect_size = rect_size + emit_signal("gut_ready") + + if _run_on_load: + # Run the test scripts. If one has been selected then only run that one + # otherwise all tests will be run. + var run_rest_of_scripts = _select_script == null + _gut.test_scripts(run_rest_of_scripts) + + +func _is_ready_to_go(action): + if _gut == null: + push_error( + str( + "GUT is not ready for ", + action, + " yet. Perform actions on GUT in/after the gut_ready signal." + ) + ) + return _gut != null + + +func _on_tests_finished(): + emit_signal("tests_finished") + + +func get_gut(): + return _gut + + +func export_if_tests_found(): + if _is_ready_to_go("export_if_tests_found"): + _gut.export_if_tests_found() + + +func import_tests_if_none_found(): + if _is_ready_to_go("import_tests_if_none_found") and !_cancel_import: + _gut.import_tests_if_none_found() diff --git a/addons/gut/printers.gd b/addons/gut/printers.gd new file mode 100644 index 0000000..1e3a644 --- /dev/null +++ b/addons/gut/printers.gd @@ -0,0 +1,153 @@ +# ------------------------------------------------------------------------------ +# 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 _gut = null + + var _colors = {red = Color.red, yellow = Color.yellow, green = Color.green} + + 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]" + + func format_text(text, fmt): + var box = _gut.get_gui().get_text_box() + + if fmt == "bold": + box.push_bold() + elif fmt == "underline": + box.push_underline() + elif _colors.has(fmt): + box.push_color(_colors[fmt]) + else: + # just pushing something to pop. + box.push_normal() + + box.add_text(text) + box.pop() + + return "" + + func _output(text): + _gut.get_gui().get_text_box().add_text(text) + + func get_gut(): + return _gut + + func set_gut(gut): + _gut = gut + + # This can be very very slow when the box has a lot of text. + func clear_line(): + var box = _gut.get_gui().get_text_box() + box.remove_line(box.get_line_count() - 1) + box.update() + + +# ------------------------------------------------------------------------------ +# 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 = PoolByteArray([0x1b]).get_string_from_ascii() + var cmd_colors = { + red = escape + "[31m", + yellow = escape + "[33m", + green = escape + "[32m", + 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/result_exporter.gd b/addons/gut/result_exporter.gd new file mode 100644 index 0000000..3ec7ae7 --- /dev/null +++ b/addons/gut/result_exporter.gd @@ -0,0 +1,116 @@ +# ------------------------------------------------------------------------------ +# 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 _utils = load("res://addons/gut/utils.gd").get_instance() + + +func _export_tests(summary_script): + var to_return = {} + var tests = summary_script.get_tests() + for key in tests.keys(): + to_return[key] = { + "status": tests[key].get_status(), + "passing": tests[key].pass_texts, + "failing": tests[key].fail_texts, + "pending": tests[key].pending_texts, + "orphans": tests[key].orphans, + } + + return to_return + + +# TODO +# errors +func _export_scripts(summary): + if summary == null: + return {} + + var scripts = {} + + for s in summary.get_scripts(): + scripts[s.name] = { + "props": + { + "tests": s._tests.size(), + "pending": s.get_pending_count(), + "failures": s.get_fail_count(), + }, + "tests": _export_tests(s) + } + 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 + }, + "scripts": [] + } + } + return result + + +# TODO +# time +# errors +func get_results_dictionary(gut, include_scripts = true): + var summary = gut.get_summary() + var scripts = [] + + if include_scripts: + scripts = _export_scripts(summary) + + var result = _make_results_dict() + if summary != null: + var totals = summary.get_totals() + + var props = result.test_scripts.props + props.pending = totals.pending + props.failures = totals.failing + props.passing = totals.passing_tests + props.tests = totals.tests + props.errors = gut.get_logger().get_errors().size() + props.warnings = gut.get_logger().get_warnings().size() + props.time = gut.get_gui().elapsed_time_as_str().replace("s", "") + props.orphans = gut.get_orphan_counter().get_counter("total") + result.test_scripts.scripts = scripts + + return result + + +func write_json_file(gut, path): + var dict = get_results_dictionary(gut) + var json = JSON.print(dict, " ") + + var f_result = _utils.write_file(path, json) + if f_result != OK: + var msg = str("Error: ", f_result, ". Could not create export file ", path) + _utils.get_logger().error(msg) + + return f_result + + +func write_summary_file(gut, path): + var dict = get_results_dictionary(gut, false) + var json = JSON.print(dict, " ") + + var f_result = _utils.write_file(path, json) + if f_result != OK: + var msg = str("Error: ", f_result, ". Could not create export file ", path) + _utils.get_logger().error(msg) + + return f_result diff --git a/addons/gut/signal_watcher.gd b/addons/gut/signal_watcher.gd new file mode 100644 index 0000000..0d0d8bb --- /dev/null +++ b/addons/gut/signal_watcher.gd @@ -0,0 +1,188 @@ +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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 _utils = load("res://addons/gut/utils.gd").get_instance() + + +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, self, "_on_watched_signal", [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(idx) + idx -= 1 + + # retrieve object and signal name from the array and remove 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() + + _watched_signals[object][signal_name].append(args) + + +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 + 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): + var did = false + if is_watching(object, signal_name): + did = get_emit_count(object, 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 _utils.is_not_freed(obj): + for signal_name in _watched_signals[obj]: + obj.disconnect(signal_name, 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 diff --git a/addons/gut/source_code_pro.fnt b/addons/gut/source_code_pro.fnt new file mode 100644 index 0000000..3367650 Binary files /dev/null and b/addons/gut/source_code_pro.fnt differ diff --git a/addons/gut/spy.gd b/addons/gut/spy.gd new file mode 100644 index 0000000..5c0d384 --- /dev/null +++ b/addons/gut/spy.gd @@ -0,0 +1,127 @@ +# { +# 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 _utils = load("res://addons/gut/utils.gd").get_instance() +var _lgr = _utils.get_logger() +var _compare = _utils.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/strutils.gd b/addons/gut/strutils.gd new file mode 100644 index 0000000..5f2c5a0 --- /dev/null +++ b/addons/gut/strutils.gd @@ -0,0 +1,185 @@ +var _utils = load("res://addons/gut/utils.gd").get_instance() +# 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 = {} +var NativeScriptClass = null + + +func _init_types_dictionary(): + types[TYPE_NIL] = "TYPE_NIL" + types[TYPE_BOOL] = "Bool" + types[TYPE_INT] = "Int" + types[TYPE_REAL] = "Float/Real" + types[TYPE_STRING] = "String" + types[TYPE_VECTOR2] = "Vector2" + types[TYPE_RECT2] = "Rect2" + types[TYPE_VECTOR3] = "Vector3" + #types[8] = 'Matrix32' + types[TYPE_PLANE] = "Plane" + types[TYPE_QUAT] = "QUAT" + types[TYPE_AABB] = "AABB" + #types[12] = 'Matrix3' + types[TYPE_TRANSFORM] = "Transform" + types[TYPE_COLOR] = "Color" + #types[15] = 'Image' + types[TYPE_NODE_PATH] = "Node Path" + types[TYPE_RID] = "RID" + types[TYPE_OBJECT] = "TYPE_OBJECT" + #types[19] = 'TYPE_INPUT_EVENT' + types[TYPE_DICTIONARY] = "Dictionary" + types[TYPE_ARRAY] = "Array" + types[TYPE_RAW_ARRAY] = "TYPE_RAW_ARRAY" + types[TYPE_INT_ARRAY] = "TYPE_INT_ARRAY" + types[TYPE_REAL_ARRAY] = "TYPE_REAL_ARRAY" + types[TYPE_STRING_ARRAY] = "TYPE_STRING_ARRAY" + types[TYPE_VECTOR2_ARRAY] = "TYPE_VECTOR2_ARRAY" + types[TYPE_VECTOR3_ARRAY] = "TYPE_VECTOR3_ARRAY" + types[TYPE_COLOR_ARRAY] = "TYPE_COLOR_ARRAY" + types[TYPE_MAX] = "TYPE_MAX" + + +# Types to not be formatted when using _str +var _str_ignore_types = [TYPE_INT, TYPE_REAL, TYPE_STRING, TYPE_NIL, TYPE_BOOL] + + +func _init(): + _init_types_dictionary() + # NativeScript does not exist when GDNative is not included in the build + if type_exists("NativeScript"): + var getter = load("res://addons/gut/get_native_script.gd") + NativeScriptClass = getter.get_it() + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +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 !is_instance_valid(thing) + or str(thing) == "[Object:null]" + or typeof(thing) != TYPE_OBJECT + or thing.has_method("__gut_instance_from_id") + ): + 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 read better. + pass + elif NativeScriptClass != null and thing.get_script() is NativeScriptClass: + # Work with GDNative scripts: + # inst2dict fails with "Not a script with an instance" on GDNative script instances + filename = _get_filename(thing.get_script().resource_path) + elif !_utils.is_native_class(thing): + var dict = inst2dict(thing) + filename = _get_filename(dict["@path"]) + if 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_REAL: + 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 _utils.is_native_class(thing): + str_thing = _utils.get_native_class_name(thing) + elif _utils.is_double(thing): + var double_path = _get_filename(thing.__gut_metadata_.path) + if thing.__gut_metadata_.subpath != "": + double_path += str("/", thing.__gut_metadata_.subpath) + elif thing.__gut_metadata_.from_singleton != "": + double_path = thing.__gut_metadata_.from_singleton + " Singleton" + + var double_type = "double" + if thing.__gut_metadata_.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/stub_params.gd b/addons/gut/stub_params.gd new file mode 100644 index 0000000..016292d --- /dev/null +++ b/addons/gut/stub_params.gd @@ -0,0 +1,117 @@ +var return_val = null +var stub_target = null +var target_subpath = null +# the parameter values to match method call on. +var parameters = null +var stub_method = null +var call_super = false + +# -- Paramter Override -- +# Parmater overrides are stored in here along with all the other stub info +# so that you can chain stubbing parameter overrides along with all the +# other stubbing. This adds some complexity to the logic that tries to +# find the correct stub for a call by a double. Since an instance of this +# class could be just a parameter override, or it could have been chained +# we have to have _paramter_override_only so that we know when to tell the +# difference. +var parameter_count = -1 +var parameter_defaults = null +# Anything that would make this stub not just an override of paramters +# must set this flag to false. This must be private bc the actual logic +# to determine if this stub is only an override is more complicated. +var _parameter_override_only = true +# -- + +const NOT_SET = "|_1_this_is_not_set_1_|" + + +func _init(target = null, method = null, subpath = null): + stub_target = target + stub_method = method + target_subpath = subpath + + +func to_return(val): + return_val = val + call_super = false + _parameter_override_only = false + return self + + +func to_do_nothing(): + return to_return(null) + + +func to_call_super(): + call_super = true + _parameter_override_only = false + 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(idx) + else: + idx += 1 + return self + + +func param_count(x): + parameter_count = x + return self + + +func param_defaults(values): + parameter_count = values.size() + parameter_defaults = values + return self + + +func has_param_override(): + return parameter_count != -1 + + +func is_param_override_only(): + var to_return = false + if has_param_override(): + to_return = _parameter_override_only + return to_return + + +func to_s(): + var base_string = str(stub_target) + if target_subpath != null: + base_string += str("[", target_subpath, "].") + else: + base_string += "." + base_string += stub_method + + if has_param_override(): + base_string += str( + " (param count override=", parameter_count, " defaults=", parameter_defaults + ) + if is_param_override_only(): + base_string += " ONLY" + base_string += ") " + + if call_super: + base_string += " to call SUPER" + + if parameters != null: + base_string += str(" with params (", parameters, ") returns ", return_val) + + return base_string diff --git a/addons/gut/stubber.gd b/addons/gut/stubber.gd new file mode 100644 index 0000000..20f6835 --- /dev/null +++ b/addons/gut/stubber.gd @@ -0,0 +1,229 @@ +# ------------- +# 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 _utils = load("res://addons/gut/utils.gd").get_instance() +var _lgr = _utils.get_logger() +var _strutils = _utils.Strutils.new() + + +func _make_key_from_metadata(doubled): + var to_return = doubled.__gut_metadata_.path + + if doubled.__gut_metadata_.from_singleton != "": + to_return = str(doubled.__gut_metadata_.from_singleton) + elif doubled.__gut_metadata_.subpath != "": + to_return += str("-", doubled.__gut_metadata_.subpath) + + return to_return + + +# Creates they key for the returns hash based on the type of object passed in +# obj could be a string of a path to a script with an optional subpath or +# it could be an instance of a doubled object. +func _make_key_from_variant(obj, subpath = null): + var to_return = null + + match typeof(obj): + TYPE_STRING: + # this has to match what is done in _make_key_from_metadata + to_return = obj + if subpath != null and subpath != "": + to_return += str("-", subpath) + TYPE_OBJECT: + if _utils.is_instance(obj): + to_return = _make_key_from_metadata(obj) + elif _utils.is_native_class(obj): + to_return = _utils.get_native_class_name(obj) + else: + to_return = obj.resource_path + + return to_return + + +func _add_obj_method(obj, method, subpath = null): + var key = _make_key_from_variant(obj, subpath) + if _utils.is_instance(obj): + key = obj + + if !returns.has(key): + returns[key] = {} + if !returns[key].has(method): + returns[key][method] = [] + + return key + + +# ############## +# Public +# ############## + + +# 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 key = _make_key_from_variant(obj) + var to_return = null + + if _utils.is_instance(obj): + if returns.has(obj) and returns[obj].has(method): + key = obj + elif obj.get("__gut_metadata_"): + key = _make_key_from_metadata(obj) + + if returns.has(key) and returns[key].has(method): + var param_match = null + var null_match = null + var overload_match = null + + for i in range(returns[key][method].size()): + if returns[key][method][i].parameters == parameters: + param_match = returns[key][method][i] + + if returns[key][method][i].parameters == null: + null_match = returns[key][method][i] + + if returns[key][method][i].has_param_override(): + overload_match = returns[key][method][i] + + 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 and !null_match.is_param_override_only(): + to_return = null_match + else: + _lgr.warn( + str( + "Call to [", + method, + "] was not stubbed for the supplied parameters ", + parameters, + ". Null was returned." + ) + ) + + return to_return + + +func add_stub(stub_params): + if stub_params.stub_method == "_init": + _lgr.error("You cannot stub _init. Super's _init is ALWAYS called.") + else: + var key = _add_obj_method( + stub_params.stub_target, stub_params.stub_method, stub_params.target_subpath + ) + 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: + return null + + +func should_call_super(obj, method, parameters = null): + if _utils.non_super_methods.has(method): + return false + + 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.__gut_metadata_.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_parameter_count(obj, method): + var to_return = null + var stub_info = _find_stub(obj, method, null, true) + + if stub_info != null and stub_info.has_param_override(): + to_return = stub_info.parameter_count + + return to_return + + +func get_default_value(obj, method, p_index): + var to_return = null + var stub_info = _find_stub(obj, method, null, true) + if ( + stub_info != null + and stub_info.parameter_defaults != null + and stub_info.parameter_defaults.size() > p_index + ): + to_return = stub_info.parameter_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 diff --git a/addons/gut/summary.gd b/addons/gut/summary.gd new file mode 100644 index 0000000..a631085 --- /dev/null +++ b/addons/gut/summary.gd @@ -0,0 +1,215 @@ +# ------------------------------------------------------------------------------ +# Contains all the results of a single test. Allows for multiple asserts results +# and pending calls. +# ------------------------------------------------------------------------------ +class Test: + var pass_texts = [] + var fail_texts = [] + var pending_texts = [] + var orphans = 0 + + # NOTE: The "failed" and "pending" text must match what is outputted by + # the logger in order for text highlighting to occur in summary. + func to_s(): + var pad = " " + var to_return = "" + for i in range(fail_texts.size()): + to_return += str(pad, "[Failed]: ", fail_texts[i], "\n") + for i in range(pending_texts.size()): + to_return += str(pad, "[Pending]: ", pending_texts[i], "\n") + return to_return + + func get_status(): + var to_return = "no asserts" + if pending_texts.size() > 0: + to_return = "pending" + elif fail_texts.size() > 0: + to_return = "fail" + elif pass_texts.size() > 0: + to_return = "pass" + + return to_return + + +# ------------------------------------------------------------------------------ +# Contains all the results for a single test-script/inner class. Persists the +# names of the tests and results and the order in which the tests were run. +# ------------------------------------------------------------------------------ +class TestScript: + var name = "NOT_SET" + var _tests = {} + var _test_order = [] + + func _init(script_name): + name = script_name + + func get_pass_count(): + var count = 0 + for key in _tests: + count += _tests[key].pass_texts.size() + return count + + func get_fail_count(): + var count = 0 + for key in _tests: + count += _tests[key].fail_texts.size() + return count + + func get_pending_count(): + var count = 0 + for key in _tests: + count += _tests[key].pending_texts.size() + return count + + func get_passing_test_count(): + var count = 0 + for key in _tests: + if _tests[key].fail_texts.size() == 0 and _tests[key].pending_texts.size() == 0: + count += 1 + return count + + func get_failing_test_count(): + var count = 0 + for key in _tests: + if _tests[key].fail_texts.size() != 0: + count += 1 + return count + + func get_test_obj(obj_name): + if !_tests.has(obj_name): + _tests[obj_name] = Test.new() + _test_order.append(obj_name) + return _tests[obj_name] + + func add_pass(test_name, reason): + var t = get_test_obj(test_name) + t.pass_texts.append(reason) + + func add_fail(test_name, reason): + var t = get_test_obj(test_name) + t.fail_texts.append(reason) + + func add_pending(test_name, reason): + var t = get_test_obj(test_name) + t.pending_texts.append(reason) + + func get_tests(): + return _tests + + +# ------------------------------------------------------------------------------ +# Summary Class +# +# This class holds the results of all the test scripts and Inner Classes that +# were run. +# ------------------------------------------------------------------------------ +var _scripts = [] + + +func add_script(name): + _scripts.append(TestScript.new(name)) + + +func get_scripts(): + return _scripts + + +func get_current_script(): + return _scripts[_scripts.size() - 1] + + +func add_test(test_name): + return get_current_script().get_test_obj(test_name) + + +func add_pass(test_name, reason = ""): + get_current_script().add_pass(test_name, reason) + + +func add_fail(test_name, reason = ""): + get_current_script().add_fail(test_name, reason) + + +func add_pending(test_name, reason = ""): + get_current_script().add_pending(test_name, reason) + + +func get_test_text(test_name): + return test_name + "\n" + get_current_script().get_test_obj(test_name).to_s() + + +# Gets the count of unique script names minus the . at the +# end. Used for displaying the number of scripts without including all the +# Inner Classes. +func get_non_inner_class_script_count(): + var unique_scripts = {} + for i in range(_scripts.size()): + var ext_loc = _scripts[i].name.find_last(".gd.") + if ext_loc == -1: + unique_scripts[_scripts[i].name] = 1 + else: + unique_scripts[_scripts[i].name.substr(0, ext_loc + 3)] = 1 + return unique_scripts.keys().size() + + +func get_totals(): + var totals = { + passing = 0, + pending = 0, + failing = 0, + tests = 0, + scripts = 0, + passing_tests = 0, + failing_tests = 0 + } + + for i in range(_scripts.size()): + totals.passing += _scripts[i].get_pass_count() + totals.pending += _scripts[i].get_pending_count() + totals.failing += _scripts[i].get_fail_count() + totals.tests += _scripts[i]._test_order.size() + totals.passing_tests += _scripts[i].get_passing_test_count() + totals.failing_tests += _scripts[i].get_failing_test_count() + + totals.scripts = get_non_inner_class_script_count() + + return totals + + +func log_summary_text(lgr): + var orig_indent = lgr.get_indent_level() + var found_failing_or_pending = false + + for s in range(_scripts.size()): + lgr.set_indent_level(0) + if _scripts[s].get_fail_count() > 0 or _scripts[s].get_pending_count() > 0: + lgr.log(_scripts[s].name, lgr.fmts.underline) + + for t in range(_scripts[s]._test_order.size()): + var tname = _scripts[s]._test_order[t] + var test = _scripts[s].get_test_obj(tname) + if test.fail_texts.size() > 0 or test.pending_texts.size() > 0: + found_failing_or_pending = true + lgr.log(str("- ", tname)) + lgr.inc_indent() + + for i in range(test.fail_texts.size()): + lgr.failed(test.fail_texts[i]) + for i in range(test.pending_texts.size()): + lgr.pending(test.pending_texts[i]) + lgr.dec_indent() + + lgr.set_indent_level(0) + if !found_failing_or_pending: + lgr.log("All tests passed", lgr.fmts.green) + + lgr.log() + var _totals = get_totals() + lgr.log("Totals", lgr.fmts.yellow) + lgr.log(str("Scripts: ", get_non_inner_class_script_count())) + lgr.log(str("Passing tests ", _totals.passing_tests)) + lgr.log(str("Failing tests ", _totals.failing_tests)) + lgr.log(str("Pending: ", _totals.pending)) + lgr.log(str("Asserts: ", _totals.passing, "/", _totals.failing)) + + lgr.set_indent_level(orig_indent) diff --git a/addons/gut/test.gd b/addons/gut/test.gd new file mode 100644 index 0000000..2923782 --- /dev/null +++ b/addons/gut/test.gd @@ -0,0 +1,1934 @@ +class_name GutTest +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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 +# ############################################################################## +extends Node + + +# ------------------------------------------------------------------------------ +# Helper class to hold info for objects to double. This extracts info and has +# some convenience methods. This is key in being able to make the "smart double" +# method which makes doubling much easier for the user. +# ----------------------------------------------------------------------------- +class DoubleInfo: + var path + var subpath + var strategy + var make_partial + var extension + var _utils = load("res://addons/gut/utils.gd").get_instance() + var _is_native = false + var is_valid = false + + # Flexible init method. p2 can be subpath or stategy unless p3 is + # specified, then p2 must be subpath and p3 is strategy. + # + # Examples: + # (object_to_double) + # (object_to_double, subpath) + # (object_to_double, strategy) + # (object_to_double, subpath, strategy) + func _init(thing, p2 = null, p3 = null): + strategy = p2 + + # short-circuit and ensure that is_valid + # is not set to true. + if _utils.is_instance(thing): + return + + if typeof(p2) == TYPE_STRING: + strategy = p3 + subpath = p2 + + if typeof(thing) == TYPE_OBJECT: + if _utils.is_native_class(thing): + path = thing + _is_native = true + extension = "native_class_not_used" + else: + path = thing.resource_path + else: + path = thing + + if !_is_native: + extension = path.get_extension() + + is_valid = true + + func is_scene(): + return extension == "tscn" + + func is_script(): + return extension == "gd" + + func is_native(): + return _is_native + + +# ------------------------------------------------------------------------------ +# Begin test.gd +# ------------------------------------------------------------------------------ +var _utils = load("res://addons/gut/utils.gd").get_instance() +var _compare = _utils.Comparator.new() + +# constant for signal when calling yield_for +const YIELD = "timeout" + +# Need a reference to the instance that is running the tests. This +# is set by the gut class when it runs the tests. This gets you +# access to the asserts in the tests you write. +var gut = null + +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 = [] + +const EDITOR_PROPERTY = PROPERTY_USAGE_SCRIPT_VARIABLE | PROPERTY_USAGE_DEFAULT +const VARIABLE_PROPERTY = PROPERTY_USAGE_SCRIPT_VARIABLE + +# Used with assert_setget +enum { DEFAULT_SETTER_GETTER, SETTER_ONLY, GETTER_ONLY } + +# 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() + +# Convenience copy of _utils.DOUBLE_STRATEGY +var DOUBLE_STRATEGY = null +var _lgr = _utils.get_logger() +var _strutils = _utils.Strutils.new() + +# syntax sugar +var ParameterFactory = _utils.ParameterFactory +var CompareResult = _utils.CompareResult +var InputFactory = _utils.InputFactory +var InputSender = _utils.InputSender + + +func _init(): + DOUBLE_STRATEGY = _utils.DOUBLE_STRATEGY # yes, this is right + + +func _str(thing): + return _strutils.type2str(thing) + + +# ------------------------------------------------------------------------------ +# 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(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] + ) + ) + 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 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 paramter values because an array was not passed.") + return invalid + + +func _create_obj_from_type(type): + var obj = null + if type.is_class("PackedScene"): + obj = type.instance() + add_child(obj) + else: + obj = type.new() + return obj + + +# ####################### +# Virtual Methods +# ####################### + + +# alias for prerun_setup +func before_all(): + pass + + +# alias for setup +func before_each(): + pass + + +# alias for postrun_teardown +func after_all(): + pass + + +# alias for teardown +func after_each(): + pass + + +# ####################### +# Public +# ####################### + + +func get_logger(): + return _lgr + + +func set_logger(logger): + _lgr = logger + + +# ####################### +# Asserts +# ####################### + + +# ------------------------------------------------------------------------------ +# Asserts that the expected value equals the value got. +# ------------------------------------------------------------------------------ +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 + + if typeof(got) == TYPE_ARRAY: + result = _compare.shallow(got, expected) + else: + result = _compare.simple(got, expected) + + if typeof(got) in [TYPE_ARRAY, TYPE_DICTIONARY]: + disp = str(result.summary, " ", text) + + if result.are_equal: + _pass(disp) + else: + _fail(disp) + + +# ------------------------------------------------------------------------------ +# Asserts that the value got does not equal the "not expected" value. +# ------------------------------------------------------------------------------ +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 + + if typeof(got) == TYPE_ARRAY: + result = _compare.shallow(got, not_expected) + else: + result = _compare.simple(got, not_expected) + + if typeof(got) in [TYPE_ARRAY, TYPE_DICTIONARY]: + disp = str(result.summary, " ", text) + + if result.are_equal: + _fail(disp) + else: + _pass(disp) + + +# ------------------------------------------------------------------------------ +# Asserts that the expected value almost equals the value got. +# ------------------------------------------------------------------------------ +func assert_almost_eq(got, expected, error_interval, text = ""): + var disp = ( + "[" + + _str(got) + + "] 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) + + +# ------------------------------------------------------------------------------ +# Asserts that the expected value does not almost equal the value got. +# ------------------------------------------------------------------------------ +func assert_almost_ne(got, not_expected, error_interval, text = ""): + var disp = ( + "[" + + _str(got) + + "] 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 which correctly compares two variables, +# while properly handling vector2/3 types +# ------------------------------------------------------------------------------ +func _is_almost_eq(got, expected, error_interval) -> bool: + var result = false + if typeof(got) == TYPE_VECTOR2: + if got.x >= (expected.x - error_interval.x) and got.x <= (expected.x + error_interval.x): + if ( + got.y >= (expected.y - error_interval.y) + and got.y <= (expected.y + error_interval.y) + ): + result = true + elif typeof(got) == TYPE_VECTOR3: + if got.x >= (expected.x - error_interval.x) and got.x <= (expected.x + error_interval.x): + if ( + got.y >= (expected.y - error_interval.y) + and got.y <= (expected.y + error_interval.y) + ): + if ( + got.z >= (expected.z - error_interval.z) + and got.z <= (expected.z + error_interval.z) + ): + result = true + elif got >= (expected - error_interval) and got <= (expected + error_interval): + result = true + return result + + +# ------------------------------------------------------------------------------ +# Asserts got is greater than expected +# ------------------------------------------------------------------------------ +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 less than expected +# ------------------------------------------------------------------------------ +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 that got is true +# ------------------------------------------------------------------------------ +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 +# ------------------------------------------------------------------------------ +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. +# ------------------------------------------------------------------------------ +func assert_between(got, expect_low, expect_high, text = ""): + var disp = ( + "[" + + _str(got) + + "] 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. +# ------------------------------------------------------------------------------ +func assert_not_between(got, expect_low, expect_high, text = ""): + var disp = ( + "[" + + _str(got) + + "] 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. +# ------------------------------------------------------------------------------ +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) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +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 that a file exists +# ------------------------------------------------------------------------------ +func assert_file_exists(file_path): + var disp = "expected [" + file_path + "] to exist." + var f = File.new() + if f.file_exists(file_path): + _pass(disp) + else: + _fail(disp) + + +# ------------------------------------------------------------------------------ +# Asserts that a file should not exist +# ------------------------------------------------------------------------------ +func assert_file_does_not_exist(file_path): + var disp = "expected [" + file_path + "] to NOT exist" + var f = File.new() + if !f.file_exists(file_path): + _pass(disp) + else: + _fail(disp) + + +# ------------------------------------------------------------------------------ +# Asserts the specified file is empty +# ------------------------------------------------------------------------------ +func assert_file_empty(file_path): + var disp = "expected [" + file_path + "] to be empty" + var f = File.new() + if f.file_exists(file_path) and gut.is_file_empty(file_path): + _pass(disp) + else: + _fail(disp) + + +# ------------------------------------------------------------------------------ +# Asserts the specified file is not empty +# ------------------------------------------------------------------------------ +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 the object has the specified 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) + + +# Old deprecated method name +func assert_get_set_methods(obj, property, default, set_to): + _lgr.deprecated("assert_get_set_methods", "assert_accessors") + assert_accessors(obj, property, default, set_to) + + +# ------------------------------------------------------------------------------ +# Verifies the object has get and set methods for the property passed in. The +# property isn't tied to anything, just a name to be appended to the end of +# get_ and set_. Asserts the get_ and set_ methods exist, if not, it stops there. +# If they exist then it asserts get_ returns the expected default then calls +# set_ and asserts get_ has the value it was set to. +# ------------------------------------------------------------------------------ +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(int) var some_value +# 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.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 a class exports a variable. +# ------------------------------------------------------------------------------ +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, connect_to_obj, method_name) + else: + var connections = signaler_obj.get_signal_connection_list(signal_name) + for conn in connections: + if (conn.source == signaler_obj) and (conn.target == connect_to_obj): + return true + return false + + +# ------------------------------------------------------------------------------ +# Watch the signals for an object. This must be called before you can make +# any assertions about the signals themselves. +# ------------------------------------------------------------------------------ +func watch_signals(object): + _signal_watcher.watch_signals(object) + + +# ------------------------------------------------------------------------------ +# Asserts that an object is connected to a signal on another object +# +# This will fail with specific messages if the target object is not connected +# to the specified signal on the source object. +# ------------------------------------------------------------------------------ +func assert_connected(signaler_obj, connect_to_obj, signal_name, method_name = ""): + pass + var method_disp = "" + if method_name != "": + method_disp = str(" using method: [", method_name, "] ") + var disp = str( + "Expected object ", + _str(signaler_obj), + " to be connected to signal: [", + signal_name, + "] on ", + _str(connect_to_obj), + method_disp + ) + if _is_connected(signaler_obj, connect_to_obj, signal_name, method_name): + _pass(disp) + else: + _fail(disp) + + +# ------------------------------------------------------------------------------ +# Asserts that an object is not connected to a signal on another object +# +# This will fail with specific messages if the target object is connected +# to the specified signal on the source object. +# ------------------------------------------------------------------------------ +func assert_not_connected(signaler_obj, connect_to_obj, signal_name, method_name = ""): + var method_disp = "" + if method_name != "": + method_disp = str(" using method: [", method_name, "] ") + var disp = str( + "Expected object ", + _str(signaler_obj), + " to not be connected to signal: [", + signal_name, + "] on ", + _str(connect_to_obj), + method_disp + ) + if _is_connected(signaler_obj, connect_to_obj, signal_name, method_name): + _fail(disp) + else: + _pass(disp) + + +# ------------------------------------------------------------------------------ +# Asserts that a signal has been emitted at least once. +# +# This will fail with specific messages if the object is not being watched or +# the object does not have the specified signal +# ------------------------------------------------------------------------------ +func assert_signal_emitted(object, signal_name, text = ""): + var disp = str( + "Expected object ", _str(object), " to have emitted signal [", signal_name, "]: ", text + ) + if _can_make_signal_assertions(object, signal_name): + if _signal_watcher.did_emit(object, signal_name): + _pass(disp) + else: + _fail(_get_fail_msg_including_emitted_signals(disp, object)) + + +# ------------------------------------------------------------------------------ +# Asserts that a signal has not been emitted. +# +# This will fail with specific messages if the object is not being watched or +# the object does not have the specified signal +# ------------------------------------------------------------------------------ +func assert_signal_not_emitted(object, signal_name, text = ""): + var disp = str( + "Expected object ", _str(object), " to NOT emit signal [", signal_name, "]: ", text + ) + if _can_make_signal_assertions(object, signal_name): + if _signal_watcher.did_emit(object, 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. +# +# This will fail with specific messages if the object is not being watched or +# the object does not have the specified signal +# ------------------------------------------------------------------------------ +func assert_signal_emitted_with_parameters(object, signal_name, parameters, index = -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(object), + " to emit signal [", + signal_name, + "] with parameters ", + parameters, + ", got " + ) + if _can_make_signal_assertions(object, signal_name): + if _signal_watcher.did_emit(object, signal_name): + var parms_got = _signal_watcher.get_signal_parameters(object, 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(object), + " to emit signal [", + signal_name, + "] with parameters ", + diff_result.summarize() + ) + ) + else: + var text = str("Object ", object, " did not emit signal [", signal_name, "]") + _fail(_get_fail_msg_including_emitted_signals(text, object)) + + +# ------------------------------------------------------------------------------ +# Assert that a signal has been emitted a specific number of times. +# +# This will fail with specific messages if the object is not being watched or +# the object does not have the specified signal +# ------------------------------------------------------------------------------ +func assert_signal_emit_count(object, signal_name, times, text = ""): + if _can_make_signal_assertions(object, signal_name): + var count = _signal_watcher.get_emit_count(object, signal_name) + var disp = str( + "Expected the signal [", + signal_name, + "] emit count of [", + count, + "] to equal [", + times, + "]: ", + text + ) + if count == times: + _pass(disp) + else: + _fail(_get_fail_msg_including_emitted_signals(disp, object)) + + +# ------------------------------------------------------------------------------ +# Assert that the passed in object has the specified signal +# ------------------------------------------------------------------------------ +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) + + +# ------------------------------------------------------------------------------ +# Returns the number of times a signal was emitted. -1 returned if the object +# is not being watched. +# ------------------------------------------------------------------------------ +func get_signal_emit_count(object, signal_name): + return _signal_watcher.get_emit_count(object, signal_name) + + +# ------------------------------------------------------------------------------ +# Get the parmaters of a fired signal. If the signal was not fired null is +# returned. You can specify an optional index (use get_signal_emit_count to +# determine the number of times it was emitted). The default index is the +# latest time the signal was fired (size() -1 insetead of 0). The parameters +# returned are in an array. +# ------------------------------------------------------------------------------ +func get_signal_parameters(object, signal_name, index = -1): + return _signal_watcher.get_signal_parameters(object, signal_name, index) + + +# ------------------------------------------------------------------------------ +# 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. +# +# 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, index = -1): + var to_return = null + if _utils.is_double(object): + to_return = gut.get_spy().get_call_parameters(object, 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. +# ------------------------------------------------------------------------------ +func get_call_count(object, method_name, parameters = null): + return gut.get_spy().call_count(object, method_name, parameters) + + +# ------------------------------------------------------------------------------ +# Assert that object is an instance of a_class +# ------------------------------------------------------------------------------ +func assert_extends(object, a_class, text = ""): + _lgr.deprecated("assert_extends", "assert_is") + assert_is(object, a_class, text) + + +# Alias for assert_extends +func assert_is(object, a_class, text = ""): + var disp = "" #var disp = str('Expected [', _str(object), '] to be type of [', a_class, ']: ', text) + var NATIVE_CLASS = "GDScriptNativeClass" + var GDSCRIPT_CLASS = "GDScript" + 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 a_class.get_class() != NATIVE_CLASS and a_class.get_class() != GDSCRIPT_CLASS: + _fail(str(bad_param_2, a_str)) + else: + if object is a_class: + _pass(disp) + else: + _fail(disp) + + +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 + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +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) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +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 given search string. +# The match_case flag determines case sensitivity. +# ------------------------------------------------------------------------------ +func assert_string_contains(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 contain '%s', match_case=%s" % [text, search, match_case] + if text == "" or search == "": + _fail(empty_search % [text, 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 given search string. +# match_case flag determines case sensitivity. +# ------------------------------------------------------------------------------ +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 text ends with given search string. +# match_case flag determines case sensitivity. +# ------------------------------------------------------------------------------ +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. +# TODO make 3rd parameter "param_or_text" and add fourth parameter of "text" and +# then work some magic so this can have a "text" parameter without being +# annoying. +# ------------------------------------------------------------------------------ +func assert_called(inst, method_name, parameters = null): + var disp = str("Expected [", method_name, "] to have been called on ", _str(inst)) + + if _fail_if_parameters_not_array(parameters): + return + + if !_utils.is_double(inst): + _fail( + "You must pass a doubled instance to assert_called. Check the wiki for info on using double." + ) + else: + if gut.get_spy().was_called(inst, method_name, parameters): + _pass(disp) + else: + if parameters != null: + disp += str(" with parameters ", parameters) + _fail(str(disp, "\n", _get_desc_of_calls_to_instance(inst))) + + +# ------------------------------------------------------------------------------ +# 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. +# ------------------------------------------------------------------------------ +func assert_not_called(inst, method_name, parameters = null): + var disp = str("Expected [", method_name, "] to NOT have been called on ", _str(inst)) + + if _fail_if_parameters_not_array(parameters): + return + + if !_utils.is_double(inst): + _fail( + "You must pass a doubled instance to assert_not_called. Check the wiki for info on using double." + ) + else: + if gut.get_spy().was_called(inst, method_name, parameters): + if parameters != null: + disp += str(" with parameters ", parameters) + _fail(str(disp, "\n", _get_desc_of_calls_to_instance(inst))) + else: + _pass(disp) + + +# ------------------------------------------------------------------------------ +# Assert that a method on an instance of a doubled class was called a number +# of times. If parameters are specified then only calls with matching +# parameter values will be counted. +# ------------------------------------------------------------------------------ +func assert_call_count(inst, method_name, expected_count, parameters = null): + var count = gut.get_spy().call_count(inst, method_name, parameters) + + if _fail_if_parameters_not_array(parameters): + return + + var param_text = "" + if parameters: + param_text = " with parameters " + str(parameters) + var disp = "Expected [%s] on %s to be called [%s] times%s. It was called [%s] times." + disp = disp % [method_name, _str(inst), expected_count, param_text, count] + + if !_utils.is_double(inst): + _fail( + "You must pass a doubled instance to assert_call_count. Check the wiki for info on using double." + ) + else: + if count == expected_count: + _pass(disp) + else: + _fail(str(disp, "\n", _get_desc_of_calls_to_instance(inst))) + + +# ------------------------------------------------------------------------------ +# 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 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 object has been freed from memory +# We pass in a title (since if it is freed, we lost all identity data) +# ----------------------------------------------------------------------------- +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) + + +# ------------------------------------------------------------------------------ +# Asserts Object has not been freed from memory +# ----------------------------------------------------------------------------- +func assert_not_freed(obj, title): + 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) + + +# ------------------------------------------------------------------------------ +# Asserts that the current test has not introduced any new orphans. This only +# applies to the test code that preceedes a call to this method so it should be +# the last thing your test does. +# ------------------------------------------------------------------------------ +func assert_no_new_orphans(text = ""): + var count = gut.get_orphan_counter().get_counter("test") + 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: + _fail(str("Expected no orphans, but found ", count, msg)) + else: + _pass("No new orphans found." + msg) + + +# ------------------------------------------------------------------------------ +# Returns a dictionary that contains +# - an is_valid flag whether validation was successful or not and +# - a message that gives some information about the validation errors. +# ------------------------------------------------------------------------------ +func _validate_assert_setget_called_input(type, name_property, name_setter, name_getter): + var obj = null + var result = {"is_valid": true, "msg": ""} + + if null == type or typeof(type) != TYPE_OBJECT or not type.is_class("Resource"): + result.is_valid = false + result.msg = str("The type parameter should be a ressource, ", _str(type), " was passed.") + return result + + if null == double(type): + result.is_valid = false + result.msg = str( + "Attempt to double the type parameter failed. The type parameter should be a ressource that can be doubled." + ) + return result + + obj = _create_obj_from_type(type) + var property = _find_object_property(obj, str(name_property)) + + if null == property: + result.is_valid = false + result.msg += str("The property %s does not exist." % _str(name_property)) + if name_setter == "" and name_getter == "": + result.is_valid = false + result.msg += str("Either setter or getter method must be specified.") + if name_setter != "" and not obj.has_method(str(name_setter)): + result.is_valid = false + result.msg += str("Setter method %s does not exist. " % _str(name_setter)) + if name_getter != "" and not obj.has_method(str(name_getter)): + result.is_valid = false + result.msg += str("Getter method %s does not exist. " % _str(name_getter)) + + obj.free() + return result + + +# ------------------------------------------------------------------------------ +# 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 + + +# ------------------------------------------------------------------------------ +# Asserts the given setter and getter methods are called when the given property +# is accessed. +# ------------------------------------------------------------------------------ +func _assert_setget_called(type, name_property, setter = "", getter = ""): + var name_setter = _utils.nvl(setter, "") + var name_getter = _utils.nvl(getter, "") + + var validation = _validate_assert_setget_called_input( + type, name_property, str(name_setter), str(name_getter) + ) + if not validation.is_valid: + _fail(validation.msg) + return + + var message = "" + var amount_calls_setter = 0 + var amount_calls_getter = 0 + var expected_calls_setter = 0 + var expected_calls_getter = 0 + var obj = _create_obj_from_type(double(type)) + + if name_setter != "": + expected_calls_setter = 1 + stub(obj, name_setter).to_do_nothing() + obj.set(name_property, null) + amount_calls_setter = gut.get_spy().call_count(obj, str(name_setter)) + + if name_getter != "": + expected_calls_getter = 1 + stub(obj, name_getter).to_do_nothing() + var __new_property = obj.get(name_property) + amount_calls_getter = gut.get_spy().call_count(obj, str(name_getter)) + + obj.free() + + # assert + + if ( + amount_calls_setter == expected_calls_setter + and amount_calls_getter == expected_calls_getter + ): + _pass(str("setget for %s is correctly configured." % _str(name_property))) + else: + if amount_calls_setter < expected_calls_setter: + message += " The setter was not called." + elif amount_calls_setter > expected_calls_setter: + message += " The setter was called but should not have been." + if amount_calls_getter < expected_calls_getter: + message += " The getter was not called." + elif amount_calls_getter > expected_calls_getter: + message += " The getter was called but should not have been." + _fail(str(message)) + + +# ------------------------------------------------------------------------------ +# Wrapper: invokes assert_setget_called but provides a slightly more convenient +# signature +# ------------------------------------------------------------------------------ +func assert_setget( + instance, name_property, const_or_setter = DEFAULT_SETTER_GETTER, getter = "__not_set__" +): + var getter_name = null + if getter != "__not_set__": + getter_name = getter + + var setter_name = null + if typeof(const_or_setter) == TYPE_INT: + if const_or_setter in [SETTER_ONLY, DEFAULT_SETTER_GETTER]: + setter_name = str("set_", name_property) + + if const_or_setter in [GETTER_ONLY, DEFAULT_SETTER_GETTER]: + getter_name = str("get_", name_property) + else: + setter_name = const_or_setter + + var resource = null + if instance.is_class("Resource"): + resource = instance + else: + resource = instance.get_script() + + _assert_setget_called(resource, str(name_property), setter_name, getter_name) + + +# ------------------------------------------------------------------------------ +# Wrapper: asserts if the property exists, the accessor methods exist and the +# setget keyword is set for accessor methods +# ------------------------------------------------------------------------------ +func assert_property(instance, name_property, default_value, new_value) -> void: + var free_me = [] + var resource = null + var obj = null + if instance.is_class("Resource"): + resource = instance + obj = _create_obj_from_type(resource) + free_me.append(obj) + else: + resource = instance.get_script() + obj = instance + + var name_setter = "set_" + str(name_property) + var name_getter = "get_" + str(name_property) + + var pre_fail_count = get_fail_count() + assert_accessors(obj, str(name_property), default_value, new_value) + _assert_setget_called(resource, str(name_property), name_setter, name_getter) + + for entry in free_me: + entry.free() + + # assert + if get_fail_count() == pre_fail_count: + _pass(str("The property is set up as expected.")) + else: + _fail(str("The property is not set up as expected. Examine subtests to see what failed.")) + + +# ------------------------------------------------------------------------------ +# Mark the current test as pending. +# ------------------------------------------------------------------------------ +func pending(text = ""): + _summary.pending += 1 + if gut: + _lgr.pending(text) + gut._pending(text) + + +# ------------------------------------------------------------------------------ +# Returns the number of times a signal was emitted. -1 returned if the object +# is not being watched. +# ------------------------------------------------------------------------------ + + +# ------------------------------------------------------------------------------ +# Yield for the time sent in. The optional message will be printed when +# Gut detects the yield. When the time expires the YIELD signal will be +# emitted. +# ------------------------------------------------------------------------------ +func yield_for(time, msg = ""): + return gut.set_yield_time(time, msg) + + +# ------------------------------------------------------------------------------ +# Yield to a signal or a maximum amount of time, whichever comes first. When +# the conditions are met the YIELD signal will be emitted. +# ------------------------------------------------------------------------------ +func yield_to(obj, signal_name, max_wait, msg = ""): + watch_signals(obj) + gut.set_yield_signal_or_time(obj, signal_name, max_wait, msg) + + return gut + + +# ------------------------------------------------------------------------------ +# Yield for a number of frames. The optional message will be printed. when +# Gut detects a yield. When the number of frames have elapsed (counted in gut's +# _process function) the YIELD signal will be emitted. +# ------------------------------------------------------------------------------ +func yield_frames(frames, msg = ""): + if frames <= 0: + var text = str( + "yeild_frames: frames must be > 0, you passed ", frames, ". 0 frames waited." + ) + _lgr.error(text) + frames = 0 + + gut.set_yield_frames(frames, msg) + return gut + + +# ------------------------------------------------------------------------------ +# Ends a test that had a yield in it. You only need to use this if you do +# not make assertions after a yield. +# ------------------------------------------------------------------------------ +func end_test(): + _lgr.deprecated("end_test is no longer necessary, you can remove it.") + #gut.end_yielded_test() + + +func get_summary(): + return _summary + + +func get_fail_count(): + return _summary.failed + + +func get_pass_count(): + return _summary.passed + + +func get_pending_count(): + return _summary.pending + + +func get_assert_count(): + return _summary.asserts + + +func clear_signal_watcher(): + _signal_watcher.clear() + + +func get_double_strategy(): + return gut.get_doubler().get_strategy() + + +func set_double_strategy(double_strategy): + gut.get_doubler().set_strategy(double_strategy) + + +func pause_before_teardown(): + gut.pause_before_teardown() + + +# ------------------------------------------------------------------------------ +# Convert the _summary dictionary into text +# ------------------------------------------------------------------------------ +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 + + +# ------------------------------------------------------------------------------ +# Double a script, inner class, or scene using a path or a loaded script/scene. +# +# +# ------------------------------------------------------------------------------ + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func _smart_double(double_info): + var override_strat = _utils.nvl(double_info.strategy, gut.get_doubler().get_strategy()) + var to_return = null + + if double_info.is_scene(): + if double_info.make_partial: + to_return = gut.get_doubler().partial_double_scene(double_info.path, override_strat) + else: + to_return = gut.get_doubler().double_scene(double_info.path, override_strat) + + elif double_info.is_native(): + if double_info.make_partial: + to_return = gut.get_doubler().partial_double_gdnative(double_info.path) + else: + to_return = gut.get_doubler().double_gdnative(double_info.path) + + elif double_info.is_script(): + if double_info.subpath == null: + if double_info.make_partial: + to_return = gut.get_doubler().partial_double(double_info.path, override_strat) + else: + to_return = gut.get_doubler().double(double_info.path, override_strat) + else: + if double_info.make_partial: + to_return = gut.get_doubler().partial_double_inner( + double_info.path, double_info.subpath, override_strat + ) + else: + to_return = gut.get_doubler().double_inner( + double_info.path, double_info.subpath, override_strat + ) + return to_return + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func double(thing, p2 = null, p3 = null): + var double_info = DoubleInfo.new(thing, p2, p3) + if !double_info.is_valid: + _lgr.error("double requires a class or path, you passed an instance: " + _str(thing)) + return null + + double_info.make_partial = false + + return _smart_double(double_info) + + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +func partial_double(thing, p2 = null, p3 = null): + var double_info = DoubleInfo.new(thing, p2, p3) + if !double_info.is_valid: + _lgr.error( + "partial_double requires a class or path, you passed an instance: " + _str(thing) + ) + return null + + double_info.make_partial = true + + return _smart_double(double_info) + + +# ------------------------------------------------------------------------------ +# Doubles a Godot singleton +# ------------------------------------------------------------------------------ +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 + + +# ------------------------------------------------------------------------------ +# Partial Doubles a Godot singleton +# ------------------------------------------------------------------------------ +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 + + +# ------------------------------------------------------------------------------ +# Specifically double a scene +# ------------------------------------------------------------------------------ +func double_scene(path, strategy = null): + var override_strat = _utils.nvl(strategy, gut.get_doubler().get_strategy()) + return gut.get_doubler().double_scene(path, override_strat) + + +# ------------------------------------------------------------------------------ +# Specifically double a script +# ------------------------------------------------------------------------------ +func double_script(path, strategy = null): + var override_strat = _utils.nvl(strategy, gut.get_doubler().get_strategy()) + return gut.get_doubler().double(path, override_strat) + + +# ------------------------------------------------------------------------------ +# Specifically double an Inner class in a a script +# ------------------------------------------------------------------------------ +func double_inner(path, subpath, strategy = null): + var override_strat = _utils.nvl(strategy, gut.get_doubler().get_strategy()) + return gut.get_doubler().double_inner(path, subpath, override_strat) + + +# ------------------------------------------------------------------------------ +# Add a method that the doubler will ignore. You can pass this the path to a +# script or scene or a loaded script or scene. When running tests, these +# ignores are cleared after every test. +# ------------------------------------------------------------------------------ +func ignore_method_when_doubling(thing, method_name): + var double_info = DoubleInfo.new(thing) + var path = double_info.path + + if double_info.is_scene(): + var inst = thing.instance() + if inst.get_script(): + path = inst.get_script().get_path() + + gut.get_doubler().add_ignored_method(path, method_name) + + +# ------------------------------------------------------------------------------ +# Stub something. +# +# Parameters +# 1: the thing to stub, a file path or a instance or a class +# 2: either an inner class subpath or the method name +# 3: the method name if an inner class subpath was specified +# NOTE: right now we cannot stub inner classes at the path level so this should +# only be called with two parameters. I did the work though so I'm going +# to leave it but not update the wiki. +# ------------------------------------------------------------------------------ +func stub(thing, p2, p3 = null): + if _utils.is_instance(thing) and !_utils.is_double(thing): + _lgr.error(str("You cannot use stub on ", _str(thing), " because it is not a double.")) + return _utils.StubParams.new() + + var method_name = p2 + var subpath = null + if p3 != null: + subpath = p2 + method_name = p3 + + var sp = _utils.StubParams.new(thing, method_name, subpath) + gut.get_stubber().add_stub(sp) + return sp + + +# ------------------------------------------------------------------------------ +# convenience wrapper. +# ------------------------------------------------------------------------------ +func simulate(obj, times, delta): + gut.simulate(obj, times, delta) + + +# ------------------------------------------------------------------------------ +# 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. +# +# The node that was replaced is queued to be freed. +# +# 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() + + +# ------------------------------------------------------------------------------ +# This method does a somewhat complicated dance with Gut. It assumes that Gut +# will clear its parameter handler after it finishes calling a parameterized test +# enough times. +# ------------------------------------------------------------------------------ +func use_parameters(params): + var ph = gut.get_parameter_handler() + if ph == null: + ph = _utils.ParameterHandler.new(params) + gut.set_parameter_handler(ph) + + var output = str( + "(call #", ph.get_call_count() + 1, ") with paramters: ", ph.get_current_parameters() + ) + _lgr.log(output) + _lgr.inc_indent() + return ph.next_parameters() + + +# ------------------------------------------------------------------------------ +# 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. + .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. + .add_child(node, legible_unique_name) + return node + + +# ------------------------------------------------------------------------------ +# 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().passed + 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().passed + 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) + + +# ------------------------------------------------------------------------------ +# 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 + + +# ------------------------------------------------------------------------------ +# Peforms a shallow compare on both values, a CompareResult instnace is returned. +# The optional max_differences paramter sets the max_differences to be displayed. +# ------------------------------------------------------------------------------ +func compare_shallow(v1, v2, max_differences = null): + var result = _compare.shallow(v1, v2) + if max_differences != null: + result.max_differences = max_differences + return result + + +# ------------------------------------------------------------------------------ +# Performs a deep compare and asserts the values are equal +# ------------------------------------------------------------------------------ +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 compare and asserts the values are not equal +# ------------------------------------------------------------------------------ +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()) + + +# ------------------------------------------------------------------------------ +# Performs a shallow compare and asserts the values are equal +# ------------------------------------------------------------------------------ +func assert_eq_shallow(v1, v2): + var result = compare_shallow(v1, v2) + if result.are_equal: + _pass(result.get_short_summary()) + else: + _fail(result.summary) + + +# ------------------------------------------------------------------------------ +# Performs a shallow compare and asserts the values are not equal +# ------------------------------------------------------------------------------ +func assert_ne_shallow(v1, v2): + var result = compare_shallow(v1, v2) + if !result.are_equal: + _pass(result.get_short_summary()) + else: + _fail(result.get_short_summary()) diff --git a/addons/gut/test_collector.gd b/addons/gut/test_collector.gd new file mode 100644 index 0000000..68f88c8 --- /dev/null +++ b/addons/gut/test_collector.gd @@ -0,0 +1,310 @@ +# ------------------------------------------------------------------------------ +# Used to keep track of info about each test ran. +# ------------------------------------------------------------------------------ +class Test: + # indicator if it passed or not. defaults to true since it takes only + # one failure to make it not pass. _fail in gut will set this. + var passed = true + # the name of the function + var name = "" + # flag to know if the name has been printed yet. + var has_printed_name = false + # the number of arguments the method has + var arg_count = 0 + # The number of asserts in the test + var assert_count = 0 + # if the test has been marked pending at anypont during + # execution. + var pending = false + + +# ------------------------------------------------------------------------------ +# This holds all the meta information for a test script. It contains the +# name of the inner class and an array of Test "structs". +# +# This class also facilitates all the exporting and importing of tests. +# ------------------------------------------------------------------------------ +class TestScript: + var inner_class_name = null + var tests = [] + var path = null + var _utils = null + var _lgr = null + + func _init(utils = null, logger = null): + _utils = utils + _lgr = logger + + 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].name, "\n") + return to_return + + func get_new(): + return load_script().new() + + func load_script(): + #print('loading: ', get_full_name()) + var to_return = load(path) + if inner_class_name != null: + # 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 + + func get_filename_and_inner(): + var to_return = get_filename() + if inner_class_name != null: + to_return += "." + inner_class_name + return to_return + + func get_full_name(): + var to_return = path + if inner_class_name != null: + to_return += "." + inner_class_name + return to_return + + func get_filename(): + return path.get_file() + + func has_inner_class(): + return inner_class_name != null + + # 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 !_utils.file_exists(source_path): + _lgr.debug("Checking for remap for: " + source_path) + var remap_path = source_path.get_basename() + ".gd.remap" + if _utils.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 = null + + func get_test_named(name): + return _utils.search_array(tests, "name", name) + + +# ------------------------------------------------------------------------------ +# start test_collector, I don't think I like the name. +# ------------------------------------------------------------------------------ +var scripts = [] +var _test_prefix = "test_" +var _test_class_prefix = "Test" + +var _utils = load("res://addons/gut/utils.gd").get_instance() +var _lgr = _utils.get_logger() + + +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 methods = test_script.load_script().get_script_method_list() + for i in range(methods.size()): + var name = methods[i]["name"] + if name.begins_with(_test_prefix): + var t = Test.new() + t.name = name + t.arg_count = methods[i]["args"].size() + test_script.tests.append(t) + + +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 _utils.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 res://addons/gut/test.gd" + ) + ) + + # 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 = load(test_script.path) + if _does_inherit_from_test(loaded): + _populate_tests(test_script) + scripts_found.append(test_script.path) + inner_classes = _get_inner_test_class_names(loaded) + + for i in range(inner_classes.size()): + var loaded_inner = loaded.get(inner_classes[i]) + if _does_inherit_from_test(loaded_inner): + var ts = TestScript.new(_utils, _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 [] + + var f = File.new() + # SHORTCIRCUIT + if !f.file_exists(path): + _lgr.error("Could not find script: " + path) + return + + var ts = TestScript.new(_utils, _lgr) + ts.path = path + scripts.append(ts) + return _parse_script(ts) + + +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("TestScript-", 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 = TestScript.new(_utils, _lgr) + ts.import_from(f, key) + _populate_tests(ts) + scripts.append(ts) + success = true + return success + + +func get_script_named(name): + return _utils.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 diff --git a/addons/gut/thing_counter.gd b/addons/gut/thing_counter.gd new file mode 100644 index 0000000..ae99b3b --- /dev/null +++ b/addons/gut/thing_counter.gd @@ -0,0 +1,51 @@ +var things = {} + + +func get_unique_count(): + return things.size() + + +func add(thing): + if things.has(thing): + things[thing] += 1 + else: + things[thing] = 1 + + +func has(thing): + return things.has(thing) + + +func get(thing): + var to_return = 0 + if things.has(thing): + to_return = things[thing] + return to_return + + +func sum(): + var count = 0 + for key in things: + count += things[key] + return count + + +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/utils.gd b/addons/gut/utils.gd new file mode 100644 index 0000000..95774e8 --- /dev/null +++ b/addons/gut/utils.gd @@ -0,0 +1,398 @@ +# ############################################################################## +#(G)odot (U)nit (T)est class +# +# ############################################################################## +# The MIT License (MIT) +# ===================== +# +# Copyright (c) 2020 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 is a PSUEDO SINGLETON. You should not make instances of it but use +# the get_instance static method. +# ############################################################################## +extends Node + + +# ------------------------------------------------------------------------------ +# The instance name as a function since you can't have static variables. +# ------------------------------------------------------------------------------ +static func INSTANCE_NAME(): + return "__GutUtilsInstName__" + + +# ------------------------------------------------------------------------------ +# 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 to_return = null + var main_loop = Engine.get_main_loop() + if main_loop != null: + return main_loop.root + else: + push_error("No Main Loop Yet") + return null + + +# ------------------------------------------------------------------------------ +# Get the ONE instance of utils +# ------------------------------------------------------------------------------ +static func get_instance(): + var the_root = get_root_node() + var inst = null + if the_root.has_node(INSTANCE_NAME()): + inst = the_root.get_node(INSTANCE_NAME()) + else: + inst = load("res://addons/gut/utils.gd").new() + inst.set_name(INSTANCE_NAME()) + the_root.add_child(inst) + return inst + + +var Logger = load("res://addons/gut/logger.gd") # everything should use get_logger +var _lgr = null + +var _test_mode = false + +var AutoFree = load("res://addons/gut/autofree.gd") +var Comparator = load("res://addons/gut/comparator.gd") +var CompareResult = load("res://addons/gut/compare_result.gd") +var DiffTool = load("res://addons/gut/diff_tool.gd") +var Doubler = load("res://addons/gut/doubler.gd") +var Gut = load("res://addons/gut/gut.gd") +var HookScript = load("res://addons/gut/hook_script.gd") +var InputFactory = load("res://addons/gut/input_factory.gd") +var InputSender = load("res://addons/gut/input_sender.gd") +var JunitXmlExport = load("res://addons/gut/junit_xml_export.gd") +var MethodMaker = load("res://addons/gut/method_maker.gd") +var OneToMany = load("res://addons/gut/one_to_many.gd") +var OrphanCounter = load("res://addons/gut/orphan_counter.gd") +var ParameterFactory = load("res://addons/gut/parameter_factory.gd") +var ParameterHandler = load("res://addons/gut/parameter_handler.gd") +var Printers = load("res://addons/gut/printers.gd") +var ResultExporter = load("res://addons/gut/result_exporter.gd") +var Spy = load("res://addons/gut/spy.gd") +var Strutils = load("res://addons/gut/strutils.gd") +var Stubber = load("res://addons/gut/stubber.gd") +var StubParams = load("res://addons/gut/stub_params.gd") +var Summary = load("res://addons/gut/summary.gd") +var Test = load("res://addons/gut/test.gd") +var TestCollector = load("res://addons/gut/test_collector.gd") +var ThingCounter = load("res://addons/gut/thing_counter.gd") + +# Source of truth for the GUT version +var version = "7.3.0" +# The required Godot version as an array. +var req_godot = [3, 2, 0] +# Used for doing file manipulation stuff so as to not keep making File instances. +# could be a bit of overkill but who cares. +var _file_checker = File.new() +# Online fetch of the latest version available on github +var latest_version = null +var should_display_latest_version = false + +# These methods all call super implicitly. Stubbing them to call super causes +# super to be called twice. +var non_super_methods = [ + "_init", + "_ready", + "_notification", + "_enter_world", + "_exit_world", + "_process", + "_physics_process", + "_exit_tree", + "_gui_input ", +] + + +func _ready() -> void: + _http_request_latest_version() + + +func _http_request_latest_version() -> void: + var http_request = HTTPRequest.new() + http_request.name = "http_request" + add_child(http_request) + http_request.connect("request_completed", self, "_on_http_request_latest_version_completed") + # Perform a GET request. The URL below returns JSON as of writing. + var error = http_request.request("https://api.github.com/repos/bitwes/Gut/releases/latest") + + +func _on_http_request_latest_version_completed(result, response_code, headers, body): + if not result == HTTPRequest.RESULT_SUCCESS: + return + + var response = parse_json(body.get_string_from_utf8()) + # Will print the user agent string used by the HTTPRequest node (as recognized by httpbin.org). + if response: + if response.get("html_url"): + latest_version = Array(response.html_url.split("/")).pop_back().right(1) + if latest_version != version: + should_display_latest_version = true + + +const GUT_METADATA = "__gut_metadata_" + +enum DOUBLE_STRATEGY { FULL, PARTIAL } + +enum DIFF { DEEP, SHALLOW, SIMPLE } + + +# ------------------------------------------------------------------------------ +# 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: ", 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 ver = PoolStringArray(req_godot).join(".") + var info = Engine.get_version_info() + var gd_version = str(info.major, ".", info.minor, ".", info.patch) + return ( + "GUT " + + version + + " requires Godot " + + ver + + " or greater. Godot version is " + + gd_version + ) + + +# ------------------------------------------------------------------------------ +# Checks the Godot version against req_godot array. +# ------------------------------------------------------------------------------ +func is_version_ok(engine_info = Engine.get_version_info(), required = req_godot): + var is_ok = null + var engine_array = [engine_info.major, engine_info.minor, engine_info.patch] + + var idx = 0 + while is_ok == null and idx < engine_array.size(): + if int(engine_array[idx]) > int(required[idx]): + is_ok = true + elif int(engine_array[idx]) < int(required[idx]): + is_ok = false + + idx += 1 + + # still null means each index was the same. + return nvl(is_ok, true) + + +# ------------------------------------------------------------------------------ +# 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. +# ------------------------------------------------------------------------------ +func get_logger(): + if _test_mode: + return Logger.new() + else: + if _lgr == null: + _lgr = Logger.new() + return _lgr + + +# ------------------------------------------------------------------------------ +# return if_null if value is null otherwise return value +# ------------------------------------------------------------------------------ +func nvl(value, if_null): + if value == null: + return if_null + else: + return value + + +# ------------------------------------------------------------------------------ +# 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. +# ------------------------------------------------------------------------------ +func is_freed(obj): + var wr = weakref(obj) + return !(wr.get_ref() and str(obj) != "[Deleted Object]") + + +# ------------------------------------------------------------------------------ +# Pretty self explanitory. +# ------------------------------------------------------------------------------ +func is_not_freed(obj): + return !is_freed(obj) + + +# ------------------------------------------------------------------------------ +# Checks if the passed in object is a GUT Double or Partial Double. +# ------------------------------------------------------------------------------ +func is_double(obj): + var to_return = false + if typeof(obj) == TYPE_OBJECT and is_instance_valid(obj): + to_return = obj.has_method("__gut_instance_from_id") + return to_return + + +# ------------------------------------------------------------------------------ +# Checks if the passed in is an instance of a class +# ------------------------------------------------------------------------------ +func is_instance(obj): + return typeof(obj) == TYPE_OBJECT and !obj.has_method("new") and !obj.has_method("instance") + + +# ------------------------------------------------------------------------------ +# Checks if the passed in is a GDScript +# ------------------------------------------------------------------------------ +func is_gdscript(obj): + return typeof(obj) == TYPE_OBJECT and str(obj).begins_with("[GDScript:") + + +# ------------------------------------------------------------------------------ +# Returns an array of values by calling get(property) on each element in source +# ------------------------------------------------------------------------------ +func extract_property_from_array(source, property): + var to_return = [] + for i in source.size(): + to_return.append(source[i].get(property)) + return to_return + + +# ------------------------------------------------------------------------------ +# true if file exists, false if not. +# ------------------------------------------------------------------------------ +func file_exists(path): + return _file_checker.file_exists(path) + + +# ------------------------------------------------------------------------------ +# Write a file. +# ------------------------------------------------------------------------------ +func write_file(path, content): + var f = File.new() + var result = f.open(path, f.WRITE) + if result == OK: + f.store_string(content) + f.close() + + return result + + +# ------------------------------------------------------------------------------ +# true if what is passed in is null or an empty string. +# ------------------------------------------------------------------------------ +func is_null_or_empty(text): + return text == null or text == "" + + +# ------------------------------------------------------------------------------ +# Get the name of a native class or null if the object passed in is not a +# native class. +# ------------------------------------------------------------------------------ +func get_native_class_name(thing): + var to_return = null + if is_native_class(thing): + var newone = thing.new() + to_return = newone.get_class() + if !newone is Reference: + newone.free() + return to_return + + +# ------------------------------------------------------------------------------ +# Checks an object to see if it is a GDScriptNativeClass +# ------------------------------------------------------------------------------ +func is_native_class(thing): + var it_is = false + if typeof(thing) == TYPE_OBJECT: + it_is = str(thing).begins_with("[GDScriptNativeClass:") + return it_is + + +# ------------------------------------------------------------------------------ +# Returns the text of a file or an empty string if the file could not be opened. +# ------------------------------------------------------------------------------ +func get_file_as_text(path): + var to_return = "" + var f = File.new() + var result = f.open(path, f.READ) + if result == OK: + to_return = f.get_as_text() + f.close() + return to_return + + +# ------------------------------------------------------------------------------ +# Loops through an array of things and calls a method or checks a property on +# each element until it finds the returned value. The item in the array is +# returned or null if it is not found. +# ------------------------------------------------------------------------------ +func search_array(ar, prop_method, value): + var found = false + var idx = 0 + + while idx < ar.size() and !found: + var item = ar[idx] + if item.get(prop_method) != null: + if item.get(prop_method) == value: + found = true + elif item.has_method(prop_method): + if item.call(prop_method) == value: + found = true + + if !found: + idx += 1 + + if found: + return ar[idx] + else: + return null + + +func are_datatypes_same(got, expected): + return !(typeof(got) != typeof(expected) and got != null and expected != null) + + +func pretty_print(dict): + print(str(JSON.print(dict, " "))) + + +func get_script_text(obj): + return obj.get_script().get_source_code() + + +func get_singleton_by_name(name): + var source = str("var singleton = ", name) + var script = GDScript.new() + script.set_source_code(source) + script.reload() + return script.new().singleton diff --git a/plug.gd b/plug.gd deleted file mode 100644 index 4ad85eb..0000000 --- a/plug.gd +++ /dev/null @@ -1,5 +0,0 @@ -extends "res://addons/gd-plug/plug.gd" - - -func _plugging(): - plug("bitwes/Gut", {commit = "48775e3cb4b0871edbbca6d88876607d7c3cd9be"}) diff --git a/project.godot b/project.godot index a914103..624eba4 100644 --- a/project.godot +++ b/project.godot @@ -8,6 +8,22 @@ config_version=4 +_global_script_classes=[ { +"base": "Reference", +"class": "GutHookScript", +"language": "GDScript", +"path": "res://addons/gut/hook_script.gd" +}, { +"base": "Node", +"class": "GutTest", +"language": "GDScript", +"path": "res://addons/gut/test.gd" +} ] +_global_script_class_icons={ +"GutHookScript": "", +"GutTest": "" +} + [application] config/name="Godot Xterm" @@ -20,7 +36,7 @@ window/vsync/use_vsync=false [editor_plugins] -enabled=PoolStringArray( "godot_xterm", "gut" ) +enabled=PoolStringArray( "res://addons/godot_xterm/plugin.cfg" ) [rendering]