summaryrefslogtreecommitdiff
path: root/addons/gdUnit4/src
diff options
context:
space:
mode:
authorradiohotline <radoihotline@disroot.rg>2026-08-25 16:33:56 +0300
committerradiohotline <radoihotline@disroot.rg>2026-08-25 16:33:56 +0300
commita89e744ed0e8c759caf041a580112365bbfe4303 (patch)
treedd61e320904176995727e86a857d00c90d4933ba /addons/gdUnit4/src
parent6d53d8cac78250dc888aa49d5f9d4175c6652feb (diff)
escoria updateHEADmain
Diffstat (limited to 'addons/gdUnit4/src')
-rw-r--r--addons/gdUnit4/src/Comparator.gd12
-rw-r--r--addons/gdUnit4/src/Comparator.gd.uid1
-rw-r--r--addons/gdUnit4/src/Fuzzers.gd80
-rw-r--r--addons/gdUnit4/src/Fuzzers.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitArrayAssert.gd122
-rw-r--r--addons/gdUnit4/src/GdUnitArrayAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitAssert.gd47
-rw-r--r--addons/gdUnit4/src/GdUnitAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitAwaiter.gd72
-rw-r--r--addons/gdUnit4/src/GdUnitAwaiter.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitBoolAssert.gd35
-rw-r--r--addons/gdUnit4/src/GdUnitBoolAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitConstants.gd10
-rw-r--r--addons/gdUnit4/src/GdUnitConstants.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitDictionaryAssert.gd79
-rw-r--r--addons/gdUnit4/src/GdUnitDictionaryAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitFailureAssert.gd52
-rw-r--r--addons/gdUnit4/src/GdUnitFailureAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitFileAssert.gd38
-rw-r--r--addons/gdUnit4/src/GdUnitFileAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitFloatAssert.gd75
-rw-r--r--addons/gdUnit4/src/GdUnitFloatAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitFuncAssert.gd42
-rw-r--r--addons/gdUnit4/src/GdUnitFuncAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitGodotErrorAssert.gd59
-rw-r--r--addons/gdUnit4/src/GdUnitGodotErrorAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitIntAssert.gd79
-rw-r--r--addons/gdUnit4/src/GdUnitIntAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitObjectAssert.gd51
-rw-r--r--addons/gdUnit4/src/GdUnitObjectAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitResultAssert.gd51
-rw-r--r--addons/gdUnit4/src/GdUnitResultAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitSceneRunner.gd350
-rw-r--r--addons/gdUnit4/src/GdUnitSceneRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitSignalAssert.gd158
-rw-r--r--addons/gdUnit4/src/GdUnitSignalAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitStringAssert.gd71
-rw-r--r--addons/gdUnit4/src/GdUnitStringAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitTestSuite.gd746
-rw-r--r--addons/gdUnit4/src/GdUnitTestSuite.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitTuple.gd86
-rw-r--r--addons/gdUnit4/src/GdUnitTuple.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitValueExtractor.gd9
-rw-r--r--addons/gdUnit4/src/GdUnitValueExtractor.gd.uid1
-rw-r--r--addons/gdUnit4/src/GdUnitVectorAssert.gd55
-rw-r--r--addons/gdUnit4/src/GdUnitVectorAssert.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/CallBackValueProvider.gd25
-rw-r--r--addons/gdUnit4/src/asserts/CallBackValueProvider.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/DefaultValueProvider.gd13
-rw-r--r--addons/gdUnit4/src/asserts/DefaultValueProvider.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdAssertMessages.gd752
-rw-r--r--addons/gdUnit4/src/asserts/GdAssertMessages.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdAssertReports.gd54
-rw-r--r--addons/gdUnit4/src/asserts/GdAssertReports.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd424
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd80
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitAssertions.gd68
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd87
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd207
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd136
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd116
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd159
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd177
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd151
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd166
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd166
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd128
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd172
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd208
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd187
-rw-r--r--addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/asserts/ValueProvider.gd10
-rw-r--r--addons/gdUnit4/src/asserts/ValueProvider.gd.uid1
-rw-r--r--addons/gdUnit4/src/cmd/CmdArgumentParser.gd62
-rw-r--r--addons/gdUnit4/src/cmd/CmdArgumentParser.gd.uid1
-rw-r--r--addons/gdUnit4/src/cmd/CmdCommand.gd27
-rw-r--r--addons/gdUnit4/src/cmd/CmdCommand.gd.uid1
-rw-r--r--addons/gdUnit4/src/cmd/CmdCommandHandler.gd136
-rw-r--r--addons/gdUnit4/src/cmd/CmdCommandHandler.gd.uid1
-rw-r--r--addons/gdUnit4/src/cmd/CmdOption.gd61
-rw-r--r--addons/gdUnit4/src/cmd/CmdOption.gd.uid1
-rw-r--r--addons/gdUnit4/src/cmd/CmdOptions.gd31
-rw-r--r--addons/gdUnit4/src/cmd/CmdOptions.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdArrayTools.gd127
-rw-r--r--addons/gdUnit4/src/core/GdArrayTools.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdDiffTool.gd224
-rw-r--r--addons/gdUnit4/src/core/GdDiffTool.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdObjects.gd726
-rw-r--r--addons/gdUnit4/src/core/GdObjects.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnit4Version.gd65
-rw-r--r--addons/gdUnit4/src/core/GdUnit4Version.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitFileAccess.gd232
-rw-r--r--addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitProperty.gd81
-rw-r--r--addons/gdUnit4/src/core/GdUnitProperty.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitResult.gd109
-rw-r--r--addons/gdUnit4/src/core/GdUnitResult.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitRunnerConfig.gd139
-rw-r--r--addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd642
-rw-r--r--addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSettings.gd443
-rw-r--r--addons/gdUnit4/src/core/GdUnitSettings.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd81
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignalCollector.gd129
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignals.gd118
-rw-r--r--addons/gdUnit4/src/core/GdUnitSignals.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitSingleton.gd56
-rw-r--r--addons/gdUnit4/src/core/GdUnitSingleton.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd97
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd20
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd410
-rw-r--r--addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GdUnitTools.gd144
-rw-r--r--addons/gdUnit4/src/core/GdUnitTools.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/GodotVersionFixures.gd11
-rw-r--r--addons/gdUnit4/src/core/GodotVersionFixures.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/LocalTime.gd114
-rw-r--r--addons/gdUnit4/src/core/LocalTime.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/_TestCase.gd253
-rw-r--r--addons/gdUnit4/src/core/_TestCase.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/assets/touch-button.pngbin0 -> 3292 bytes
-rw-r--r--addons/gdUnit4/src/core/assets/touch-button.png.import40
-rw-r--r--addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd76
-rw-r--r--addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd64
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd42
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd17
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd15
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd126
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd27
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd57
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd26
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd26
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd25
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd22
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd56
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd41
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd14
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd14
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd21
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd124
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitShortcut.gd73
-rw-r--r--addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitGUID.gd46
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd125
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd323
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd13
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd171
-rw-r--r--addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEvent.gd207
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventInit.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventStop.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd19
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitSessionClose.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitSessionStart.gd6
-rw-r--r--addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd274
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd135
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd77
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd56
-rw-r--r--addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd22
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd20
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd37
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd29
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd14
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd147
-rw-r--r--addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd37
-rw-r--r--addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd52
-rw-r--r--addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd55
-rw-r--r--addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd53
-rw-r--r--addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd11
-rw-r--r--addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd78
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd9
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd111
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd191
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd11
-rw-r--r--addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdClassDescriptor.gd25
-rw-r--r--addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd290
-rw-r--r--addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionArgument.gd208
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd286
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd188
-rw-r--r--addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdScriptParser.gd764
-rw-r--r--addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd74
-rw-r--r--addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd163
-rw-r--r--addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/report/GdUnitReport.gd84
-rw-r--r--addons/gdUnit4/src/core/report/GdUnitReport.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd470
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd90
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn34
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSession.gd169
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd180
-rw-r--r--addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd36
-rw-r--r--addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd144
-rw-r--r--addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd70
-rw-r--r--addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd69
-rw-r--r--addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd227
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd214
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd115
-rw-r--r--addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs235
-rw-r--r--addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd114
-rw-r--r--addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/CallableDoubler.gd156
-rw-r--r--addons/gdUnit4/src/doubler/CallableDoubler.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdFunctionDoubler.gd4
-rw-r--r--addons/gdUnit4/src/doubler/GdFunctionDoubler.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd119
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd336
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd10
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd53
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd84
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd.uid1
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd8
-rw-r--r--addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd.uid1
-rw-r--r--addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd73
-rw-r--r--addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/BoolFuzzer.gd24
-rw-r--r--addons/gdUnit4/src/fuzzers/BoolFuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/FloatFuzzer.gd37
-rw-r--r--addons/gdUnit4/src/fuzzers/FloatFuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/Fuzzer.gd80
-rw-r--r--addons/gdUnit4/src/fuzzers/Fuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/IntFuzzer.gd77
-rw-r--r--addons/gdUnit4/src/fuzzers/IntFuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/StringFuzzer.gd112
-rw-r--r--addons/gdUnit4/src/fuzzers/StringFuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd45
-rw-r--r--addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd48
-rw-r--r--addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd11
-rw-r--r--addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd50
-rw-r--r--addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd32
-rw-r--r--addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd22
-rw-r--r--addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd22
-rw-r--r--addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd13
-rw-r--r--addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd42
-rw-r--r--addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd.uid1
-rw-r--r--addons/gdUnit4/src/mocking/GdUnitMock.gd45
-rw-r--r--addons/gdUnit4/src/mocking/GdUnitMock.gd.uid1
-rw-r--r--addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd186
-rw-r--r--addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid1
-rw-r--r--addons/gdUnit4/src/mocking/GdUnitMockImpl.gd143
-rw-r--r--addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/monitor/ErrorLogEntry.gd41
-rw-r--r--addons/gdUnit4/src/monitor/ErrorLogEntry.gd.uid1
-rw-r--r--addons/gdUnit4/src/monitor/GdUnitMonitor.gd24
-rw-r--r--addons/gdUnit4/src/monitor/GdUnitMonitor.gd.uid1
-rw-r--r--addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd70
-rw-r--r--addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd.uid1
-rw-r--r--addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd221
-rw-r--r--addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid1
-rw-r--r--addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd122
-rw-r--r--addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/GdUnitServer.gd41
-rw-r--r--addons/gdUnit4/src/network/GdUnitServer.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/GdUnitServer.tscn10
-rw-r--r--addons/gdUnit4/src/network/GdUnitServerConstants.gd6
-rw-r--r--addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/GdUnitTask.gd25
-rw-r--r--addons/gdUnit4/src/network/GdUnitTask.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/GdUnitTcpClient.gd126
-rw-r--r--addons/gdUnit4/src/network/GdUnitTcpClient.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/GdUnitTcpNode.gd73
-rw-r--r--addons/gdUnit4/src/network/GdUnitTcpNode.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/GdUnitTcpServer.gd140
-rw-r--r--addons/gdUnit4/src/network/GdUnitTcpServer.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/rpc/RPC.gd37
-rw-r--r--addons/gdUnit4/src/network/rpc/RPC.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCClientConnect.gd13
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCClientConnect.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd13
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd14
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd.uid1
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCMessage.gd18
-rw-r--r--addons/gdUnit4/src/network/rpc/RPCMessage.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitReportSummary.gd202
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitReportWriter.gd12
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd47
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitTestReporter.gd112
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd96
-rw-r--r--addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd235
-rw-r--r--addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd60
-rw-r--r--addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd199
-rw-r--r--addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd72
-rw-r--r--addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/html/template/.gdignore0
-rw-r--r--addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css66
-rw-r--r--addons/gdUnit4/src/reporters/html/template/css/logo.pngbin0 -> 49775 bytes
-rw-r--r--addons/gdUnit4/src/reporters/html/template/css/styles.css475
-rw-r--r--addons/gdUnit4/src/reporters/html/template/folder_report.html122
-rw-r--r--addons/gdUnit4/src/reporters/html/template/index.html164
-rw-r--r--addons/gdUnit4/src/reporters/html/template/suite_report.html177
-rw-r--r--addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd143
-rw-r--r--addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid1
-rw-r--r--addons/gdUnit4/src/reporters/xml/XmlElement.gd69
-rw-r--r--addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid1
-rw-r--r--addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd154
-rw-r--r--addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd.uid1
-rw-r--r--addons/gdUnit4/src/spy/GdUnitSpyImpl.gd46
-rw-r--r--addons/gdUnit4/src/spy/GdUnitSpyImpl.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/GdUnitConsole.gd91
-rw-r--r--addons/gdUnit4/src/ui/GdUnitConsole.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/GdUnitConsole.tscn64
-rw-r--r--addons/gdUnit4/src/ui/GdUnitFonts.gd36
-rw-r--r--addons/gdUnit4/src/ui/GdUnitFonts.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/GdUnitInspector.gd34
-rw-r--r--addons/gdUnit4/src/ui/GdUnitInspector.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/GdUnitInspector.tscn70
-rw-r--r--addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd31
-rw-r--r--addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/GdUnitUiTools.gd151
-rw-r--r--addons/gdUnit4/src/ui/GdUnitUiTools.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/ScriptEditorControls.gd100
-rw-r--r--addons/gdUnit4/src/ui/ScriptEditorControls.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd45
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd37
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd63
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.tscn70
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd41
-rw-r--r--addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorMonitor.gd54
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorMonitor.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorMonitor.tscn94
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd49
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorProgressBar.tscn33
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd209
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorStatusBar.tscn465
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorToolBar.gd104
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorToolBar.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorToolBar.tscn202
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd1210
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/parts/InspectorTreePanel.tscn240
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd56
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn36
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd327
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn349
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd255
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn148
-rw-r--r--addons/gdUnit4/src/ui/settings/logo.pngbin0 -> 49775 bytes
-rw-r--r--addons/gdUnit4/src/ui/settings/logo.png.import40
-rw-r--r--addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd129
-rw-r--r--addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd.uid1
-rw-r--r--addons/gdUnit4/src/ui/templates/TestSuiteTemplate.tscn127
-rw-r--r--addons/gdUnit4/src/update/GdMarkDownReader.gd405
-rw-r--r--addons/gdUnit4/src/update/GdMarkDownReader.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitPatch.gd20
-rw-r--r--addons/gdUnit4/src/update/GdUnitPatch.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitPatcher.gd75
-rw-r--r--addons/gdUnit4/src/update/GdUnitPatcher.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdate.gd305
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdate.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdate.tscn100
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateClient.gd98
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateNotify.gd206
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid1
-rw-r--r--addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn97
-rw-r--r--addons/gdUnit4/src/update/assets/border_bottom.pngbin0 -> 1757 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/border_bottom.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/border_top.pngbin0 -> 1749 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/border_top.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/dot1.pngbin0 -> 730 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/dot1.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/dot2.pngbin0 -> 883 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/dot2.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/embedded.pngbin0 -> 287 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/embedded.png.import40
-rw-r--r--addons/gdUnit4/src/update/assets/horizontal-line2.pngbin0 -> 332 bytes
-rw-r--r--addons/gdUnit4/src/update/assets/horizontal-line2.png.import40
484 files changed, 29916 insertions, 0 deletions
diff --git a/addons/gdUnit4/src/Comparator.gd b/addons/gdUnit4/src/Comparator.gd
new file mode 100644
index 0000000..096088a
--- /dev/null
+++ b/addons/gdUnit4/src/Comparator.gd
@@ -0,0 +1,12 @@
+class_name Comparator
+extends Resource
+
+enum {
+ EQUAL,
+ LESS_THAN,
+ LESS_EQUAL,
+ GREATER_THAN,
+ GREATER_EQUAL,
+ BETWEEN_EQUAL,
+ NOT_BETWEEN_EQUAL,
+}
diff --git a/addons/gdUnit4/src/Comparator.gd.uid b/addons/gdUnit4/src/Comparator.gd.uid
new file mode 100644
index 0000000..6da8cd4
--- /dev/null
+++ b/addons/gdUnit4/src/Comparator.gd.uid
@@ -0,0 +1 @@
+uid://dmyhfgv1bt2lh
diff --git a/addons/gdUnit4/src/Fuzzers.gd b/addons/gdUnit4/src/Fuzzers.gd
new file mode 100644
index 0000000..c8689df
--- /dev/null
+++ b/addons/gdUnit4/src/Fuzzers.gd
@@ -0,0 +1,80 @@
+## Factory class providing convenient static methods to create various fuzzer instances.[br]
+##
+## Fuzzers is a utility class that simplifies the creation of different fuzzer types
+## for testing purposes. It provides static factory methods that create pre-configured
+## fuzzers with sensible defaults, making it easier to set up fuzz testing in your
+## test suites without manually instantiating each fuzzer type.[br]
+##
+## This class acts as a central access point for all fuzzer types, improving code
+## readability and reducing boilerplate in test cases.[br]
+##
+## @tutorial(Fuzzing Testing): https://en.wikipedia.org/wiki/Fuzzing
+class_name Fuzzers
+extends Resource
+
+
+## Generates random strings with length between [param min_length] and
+## [param max_length] (inclusive), using characters from [param charset].
+## See [StringFuzzer] for detailed documentation and examples.
+static func rand_str(min_length: int, max_length: int, charset := StringFuzzer.DEFAULT_CHARSET) -> StringFuzzer:
+ return StringFuzzer.new(min_length, max_length, charset)
+
+
+## Creates a [BoolFuzzer] for generating random boolean values.[br]
+##
+## See [BoolFuzzer] for detailed documentation and examples.
+static func boolean() -> BoolFuzzer:
+ return BoolFuzzer.new()
+
+
+## Creates an [IntFuzzer] for generating random integers within a range.[br]
+##
+## Generates random integers between [param from] and [param to] (inclusive)
+## using [constant IntFuzzer.NORMAL] mode.
+## See [IntFuzzer] for detailed documentation and examples.
+static func rangei(from: int, to: int) -> IntFuzzer:
+ return IntFuzzer.new(from, to)
+
+
+## Creates a [FloatFuzzer] for generating random floats within a range.[br]
+##
+## Generates random float values between [param from] and [param to] (inclusive).
+## See [FloatFuzzer] for detailed documentation and examples.
+static func rangef(from: float, to: float) -> FloatFuzzer:
+ return FloatFuzzer.new(from, to)
+
+
+## Creates a [Vector2Fuzzer] for generating random 2D vectors within a range.[br]
+##
+## Generates random Vector2 values where each component is bounded by
+## [param from] and [param to] (inclusive).
+## See [Vector2Fuzzer] for detailed documentation and examples.
+static func rangev2(from: Vector2, to: Vector2) -> Vector2Fuzzer:
+ return Vector2Fuzzer.new(from, to)
+
+
+## Creates a [Vector3Fuzzer] for generating random 3D vectors within a range.[br]
+##
+## Generates random Vector3 values where each component is bounded by
+## [param from] and [param to] (inclusive).
+## See [Vector3Fuzzer] for detailed documentation and examples.
+static func rangev3(from: Vector3, to: Vector3) -> Vector3Fuzzer:
+ return Vector3Fuzzer.new(from, to)
+
+
+## Creates an [IntFuzzer] that generates only even integers.[br]
+##
+## Generates random even integers between [param from] and [param to] (inclusive)
+## using [constant IntFuzzer.EVEN] mode.
+## See [IntFuzzer] for detailed documentation about even number generation.
+static func eveni(from: int, to: int) -> IntFuzzer:
+ return IntFuzzer.new(from, to, IntFuzzer.EVEN)
+
+
+## Creates an [IntFuzzer] that generates only odd integers.[br]
+##
+## Generates random odd integers between [param from] and [param to] (inclusive)
+## using [constant IntFuzzer.ODD] mode.
+## See [IntFuzzer] for detailed documentation about odd number generation.
+static func oddi(from: int, to: int) -> IntFuzzer:
+ return IntFuzzer.new(from, to, IntFuzzer.ODD)
diff --git a/addons/gdUnit4/src/Fuzzers.gd.uid b/addons/gdUnit4/src/Fuzzers.gd.uid
new file mode 100644
index 0000000..1168c34
--- /dev/null
+++ b/addons/gdUnit4/src/Fuzzers.gd.uid
@@ -0,0 +1 @@
+uid://dbbi3yw3gch5l
diff --git a/addons/gdUnit4/src/GdUnitArrayAssert.gd b/addons/gdUnit4/src/GdUnitArrayAssert.gd
new file mode 100644
index 0000000..eeb7ca6
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitArrayAssert.gd
@@ -0,0 +1,122 @@
+## An Assertion Tool to verify array values
+@abstract class_name GdUnitArrayAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitArrayAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitArrayAssert
+
+
+## Verifies that the current Array is equal to the given one.
+@abstract func is_equal(...expected: Array) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array is equal to the given one, ignoring case considerations.
+@abstract func is_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array is not equal to the given one.
+@abstract func is_not_equal(...expected: Array) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array is not equal to the given one, ignoring case considerations.
+@abstract func is_not_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitArrayAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array is empty, it has a size of 0.
+@abstract func is_empty() -> GdUnitArrayAssert
+
+
+## Verifies that the current Array is not empty, it has a size of minimum 1.
+@abstract func is_not_empty() -> GdUnitArrayAssert
+
+
+## Verifies that the current Array is the same. [br]
+## Compares the current by object reference equals
+@abstract func is_same(expected: Variant) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array is NOT the same. [br]
+## Compares the current by object reference equals
+@abstract func is_not_same(expected: Variant) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array has a size of given value.
+@abstract func has_size(expectd: int) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array contains the given values, in any order.[br]
+## The values are compared by deep parameter comparision, for object reference compare you have to use [method contains_same]
+@abstract func contains(...expected: Array) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array contains exactly only the given values and nothing else, in same order.[br]
+## The values are compared by deep parameter comparision, for object reference compare you have to use [method contains_same_exactly]
+@abstract func contains_exactly(...expected: Array) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array contains exactly only the given values and nothing else, in any order.[br]
+## The values are compared by deep parameter comparision, for object reference compare you have to use [method contains_same_exactly_in_any_order]
+@abstract func contains_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array contains the given values, in any order.[br]
+## The values are compared by object reference, for deep parameter comparision use [method contains]
+@abstract func contains_same(...expected: Array) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array contains exactly only the given values and nothing else, in same order.[br]
+## The values are compared by object reference, for deep parameter comparision use [method contains_exactly]
+@abstract func contains_same_exactly(...expected: Array) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array contains exactly only the given values and nothing else, in any order.[br]
+## The values are compared by object reference, for deep parameter comparision use [method contains_exactly_in_any_order]
+@abstract func contains_same_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array do NOT contains the given values, in any order.[br]
+## The values are compared by deep parameter comparision, for object reference compare you have to use [method not_contains_same]
+## [b]Example:[/b]
+## [codeblock]
+## # will succeed
+## assert_array([1, 2, 3, 4, 5]).not_contains(6)
+## # will fail
+## assert_array([1, 2, 3, 4, 5]).not_contains(2, 6)
+## [/codeblock]
+@abstract func not_contains(...expected: Array) -> GdUnitArrayAssert
+
+
+## Verifies that the current Array do NOT contains the given values, in any order.[br]
+## The values are compared by object reference, for deep parameter comparision use [method not_contains]
+## [b]Example:[/b]
+## [codeblock]
+## # will succeed
+## assert_array([1, 2, 3, 4, 5]).not_contains(6)
+## # will fail
+## assert_array([1, 2, 3, 4, 5]).not_contains(2, 6)
+## [/codeblock]
+@abstract func not_contains_same(...expected: Array) -> GdUnitArrayAssert
+
+
+## Extracts all values by given function name and optional arguments into a new ArrayAssert.
+## If the elements not accessible by `func_name` the value is converted to `"n.a"`, expecting null values
+@abstract func extract(func_name: String, ...func_args: Array) -> GdUnitArrayAssert
+
+
+## Extracts all values by given extractor's into a new ArrayAssert.
+## If the elements not extractable than the value is converted to `"n.a"`, expecting null values
+## -- The argument type is Array[GdUnitValueExtractor]
+@abstract func extractv(...extractors: Array) -> GdUnitArrayAssert
diff --git a/addons/gdUnit4/src/GdUnitArrayAssert.gd.uid b/addons/gdUnit4/src/GdUnitArrayAssert.gd.uid
new file mode 100644
index 0000000..0da97e4
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitArrayAssert.gd.uid
@@ -0,0 +1 @@
+uid://cunpqk22lw7v7
diff --git a/addons/gdUnit4/src/GdUnitAssert.gd b/addons/gdUnit4/src/GdUnitAssert.gd
new file mode 100644
index 0000000..41382d9
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitAssert.gd
@@ -0,0 +1,47 @@
+## Base interface of all GdUnit asserts
+@abstract class_name GdUnitAssert
+extends RefCounted
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitAssert
+
+
+## Verifies that the current value is equal to expected one.
+@abstract func is_equal(expected: Variant) -> GdUnitAssert
+
+
+## Verifies that the current value is not equal to expected one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitAssert
+
+
+## Overrides the default failure message by given custom message.[br]
+## This function allows you to replace the automatically generated failure message with a more specific
+## or user-friendly message that better describes the test failure context.[br]
+## Usage:
+## [codeblock]
+## # Override with custom context-specific message
+## func test_player_inventory():
+## assert_that(player.get_item_count("sword"))\
+## .override_failure_message("Player should have exactly one sword")\
+## .is_equal(1)
+## [/codeblock]
+@abstract func override_failure_message(message: String) -> GdUnitAssert
+
+
+## Appends a custom message to the failure message.[br]
+## This can be used to add additional information to the generated failure message
+## while keeping the original assertion details for better debugging context.[br]
+## Usage:
+## [codeblock]
+## # Add context to existing failure message
+## func test_player_health():
+## assert_that(player.health)\
+## .append_failure_message("Player was damaged by: %s" % last_damage_source)\
+## .is_greater(0)
+## [/codeblock]
+@abstract func append_failure_message(message: String) -> GdUnitAssert
diff --git a/addons/gdUnit4/src/GdUnitAssert.gd.uid b/addons/gdUnit4/src/GdUnitAssert.gd.uid
new file mode 100644
index 0000000..cb02773
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitAssert.gd.uid
@@ -0,0 +1 @@
+uid://drtok3us6xriq
diff --git a/addons/gdUnit4/src/GdUnitAwaiter.gd b/addons/gdUnit4/src/GdUnitAwaiter.gd
new file mode 100644
index 0000000..51385e8
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitAwaiter.gd
@@ -0,0 +1,72 @@
+class_name GdUnitAwaiter
+extends RefCounted
+
+
+# Waits for a specified signal in an interval of 50ms sent from the <source>, and terminates with an error after the specified timeout has elapsed.
+# source: the object from which the signal is emitted
+# signal_name: signal name
+# args: the expected signal arguments as an array
+# timeout: the timeout in ms, default is set to 2000ms
+func await_signal_on(source :Object, signal_name :String, args :Array = [], timeout_millis :int = 2000) -> Variant:
+ # fail fast if the given source instance invalid
+ var assert_that := GdUnitAssertImpl.new(signal_name)
+ var line_number := GdUnitAssertions.get_line_number()
+ if not is_instance_valid(source):
+ @warning_ignore("return_value_discarded")
+ assert_that.report_error(GdAssertMessages.error_await_signal_on_invalid_instance(source, signal_name, args), line_number)
+ return await (Engine.get_main_loop() as SceneTree).process_frame
+ # fail fast if the given source instance invalid
+ if not is_instance_valid(source):
+ @warning_ignore("return_value_discarded")
+ assert_that.report_error(GdAssertMessages.error_await_signal_on_invalid_instance(source, signal_name, args), line_number)
+ return await await_idle_frame()
+ var awaiter := GdUnitSignalAwaiter.new(timeout_millis)
+ var value :Variant = await awaiter.on_signal(source, signal_name, args)
+ if awaiter.is_interrupted():
+ var failure := "await_signal_on(%s, %s) timed out after %sms" % [signal_name, args, timeout_millis]
+ @warning_ignore("return_value_discarded")
+ assert_that.report_error(failure, line_number)
+ return value
+
+
+# Waits for a specified signal sent from the <source> between idle frames and aborts with an error after the specified timeout has elapsed
+# source: the object from which the signal is emitted
+# signal_name: signal name
+# args: the expected signal arguments as an array
+# timeout: the timeout in ms, default is set to 2000ms
+func await_signal_idle_frames(source :Object, signal_name :String, args :Array = [], timeout_millis :int = 2000) -> Variant:
+ var line_number := GdUnitAssertions.get_line_number()
+ # fail fast if the given source instance invalid
+ if not is_instance_valid(source):
+ @warning_ignore("return_value_discarded")
+ GdUnitAssertImpl.new(signal_name)\
+ .report_error(GdAssertMessages.error_await_signal_on_invalid_instance(source, signal_name, args), line_number)
+ return await await_idle_frame()
+ var awaiter := GdUnitSignalAwaiter.new(timeout_millis, true)
+ var value :Variant = await awaiter.on_signal(source, signal_name, args)
+ if awaiter.is_interrupted():
+ var failure := "await_signal_idle_frames(%s, %s) timed out after %sms" % [signal_name, args, timeout_millis]
+ @warning_ignore("return_value_discarded")
+ GdUnitAssertImpl.new(signal_name).report_error(failure, line_number)
+ return value
+
+
+# Waits for for a given amount of milliseconds
+# example:
+# # waits for 100ms
+# await GdUnitAwaiter.await_millis(myNode, 100).completed
+# use this waiter and not `await get_tree().create_timer().timeout to prevent errors when a test case is timed out
+func await_millis(milliSec :int) -> void:
+ var timer :Timer = Timer.new()
+ timer.set_name("gdunit_await_millis_timer_%d" % timer.get_instance_id())
+ (Engine.get_main_loop() as SceneTree).root.add_child(timer)
+ timer.add_to_group("GdUnitTimers")
+ timer.set_one_shot(true)
+ timer.start(milliSec / 1000.0)
+ await timer.timeout
+ timer.queue_free()
+
+
+# Waits until the next idle frame
+func await_idle_frame() -> void:
+ await (Engine.get_main_loop() as SceneTree).process_frame
diff --git a/addons/gdUnit4/src/GdUnitAwaiter.gd.uid b/addons/gdUnit4/src/GdUnitAwaiter.gd.uid
new file mode 100644
index 0000000..d5f86cb
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitAwaiter.gd.uid
@@ -0,0 +1 @@
+uid://bxu4aimnsxf6a
diff --git a/addons/gdUnit4/src/GdUnitBoolAssert.gd b/addons/gdUnit4/src/GdUnitBoolAssert.gd
new file mode 100644
index 0000000..714f8fc
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitBoolAssert.gd
@@ -0,0 +1,35 @@
+## An Assertion Tool to verify boolean values
+@abstract class_name GdUnitBoolAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitBoolAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitBoolAssert
+
+
+## Verifies that the current value is equal to the given one.
+@abstract func is_equal(expected: Variant) -> GdUnitBoolAssert
+
+
+## Verifies that the current value is not equal to the given one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitBoolAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitBoolAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitBoolAssert
+
+
+## Verifies that the current value is true.
+@abstract func is_true() -> GdUnitBoolAssert
+
+
+## Verifies that the current value is false.
+@abstract func is_false() -> GdUnitBoolAssert
diff --git a/addons/gdUnit4/src/GdUnitBoolAssert.gd.uid b/addons/gdUnit4/src/GdUnitBoolAssert.gd.uid
new file mode 100644
index 0000000..1b38958
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitBoolAssert.gd.uid
@@ -0,0 +1 @@
+uid://chfmmumtcb0rt
diff --git a/addons/gdUnit4/src/GdUnitConstants.gd b/addons/gdUnit4/src/GdUnitConstants.gd
new file mode 100644
index 0000000..e43c75a
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitConstants.gd
@@ -0,0 +1,10 @@
+class_name GdUnitConstants
+extends RefCounted
+
+const NO_ARG :Variant = "<--null-->"
+
+const EXPECT_ASSERT_REPORT_FAILURES := "expect_assert_report_failures"
+
+## The maximum number of report history files to store
+const DEFAULT_REPORT_HISTORY_COUNT = 20
+const REPORT_DIR_PREFIX = "report_"
diff --git a/addons/gdUnit4/src/GdUnitConstants.gd.uid b/addons/gdUnit4/src/GdUnitConstants.gd.uid
new file mode 100644
index 0000000..887fffc
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitConstants.gd.uid
@@ -0,0 +1 @@
+uid://bt8507s1msjml
diff --git a/addons/gdUnit4/src/GdUnitDictionaryAssert.gd b/addons/gdUnit4/src/GdUnitDictionaryAssert.gd
new file mode 100644
index 0000000..45cc62a
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitDictionaryAssert.gd
@@ -0,0 +1,79 @@
+## An Assertion Tool to verify dictionary
+@abstract class_name GdUnitDictionaryAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitDictionaryAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary is equal to the given one, ignoring order.
+@abstract func is_equal(expected: Variant) -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary is not equal to the given one, ignoring order.
+@abstract func is_not_equal(expected: Variant) -> GdUnitDictionaryAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitDictionaryAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary is empty, it has a size of 0.
+@abstract func is_empty() -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary is not empty, it has a size of minimum 1.
+@abstract func is_not_empty() -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary is the same. [br]
+## Compares the current by object reference equals
+@abstract func is_same(expected: Variant) -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary is NOT the same. [br]
+## Compares the current by object reference equals
+@abstract func is_not_same(expected: Variant) -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary has a size of given value.
+@abstract func has_size(expected: int) -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary contains the given key(s).[br]
+## The keys are compared by deep parameter comparision, for object reference compare you have to use [method contains_same_keys]
+@abstract func contains_keys(...expected: Array) -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary contains the given key and value.[br]
+## The key and value are compared by deep parameter comparision, for object reference compare you have to use [method contains_same_key_value]
+@abstract func contains_key_value(key: Variant, value: Variant) -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary not contains the given key(s).[br]
+## The keys are compared by deep parameter comparision, for object reference compare you have to use [method not_contains_same_keys]
+@abstract func not_contains_keys(...expected: Array) -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary contains the given key(s).[br]
+## The keys are compared by object reference, for deep parameter comparision use [method contains_keys]
+@abstract func contains_same_keys(expected: Array) -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary contains the given key and value.[br]
+## The key and value are compared by object reference, for deep parameter comparision use [method contains_key_value]
+@abstract func contains_same_key_value(key: Variant, value: Variant) -> GdUnitDictionaryAssert
+
+
+## Verifies that the current dictionary not contains the given key(s).
+## The keys are compared by object reference, for deep parameter comparision use [method not_contains_keys]
+@abstract func not_contains_same_keys(...expected: Array) -> GdUnitDictionaryAssert
diff --git a/addons/gdUnit4/src/GdUnitDictionaryAssert.gd.uid b/addons/gdUnit4/src/GdUnitDictionaryAssert.gd.uid
new file mode 100644
index 0000000..8c20dba
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitDictionaryAssert.gd.uid
@@ -0,0 +1 @@
+uid://dqrcom5g63buq
diff --git a/addons/gdUnit4/src/GdUnitFailureAssert.gd b/addons/gdUnit4/src/GdUnitFailureAssert.gd
new file mode 100644
index 0000000..6fec191
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFailureAssert.gd
@@ -0,0 +1,52 @@
+## An assertion tool to verify GDUnit asserts.
+## This assert is for internal use only, to verify that failed asserts work as expected.
+@abstract class_name GdUnitFailureAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitFailureAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitFailureAssert
+
+
+## Verifies that the current value is equal to the given one.
+@abstract func is_equal(expected: Variant) -> GdUnitFailureAssert
+
+
+## Verifies that the current value is not equal to expected one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitFailureAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitFailureAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitFailureAssert
+
+
+## Verifies if the executed assert was successful
+@abstract func is_success() -> GdUnitFailureAssert
+
+
+## Verifies if the executed assert has failed
+@abstract func is_failed() -> GdUnitFailureAssert
+
+
+## Verifies the failure line is equal to expected one.
+@abstract func has_line(expected: int) -> GdUnitFailureAssert
+
+
+## Verifies the failure message is equal to expected one.
+@abstract func has_message(expected: String) -> GdUnitFailureAssert
+
+
+## Verifies that the failure message starts with the expected message.
+@abstract func starts_with_message(expected: String) -> GdUnitFailureAssert
+
+
+## Verifies that the failure message contains the expected message.
+@abstract func contains_message(expected: String) -> GdUnitFailureAssert
diff --git a/addons/gdUnit4/src/GdUnitFailureAssert.gd.uid b/addons/gdUnit4/src/GdUnitFailureAssert.gd.uid
new file mode 100644
index 0000000..339bdc4
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFailureAssert.gd.uid
@@ -0,0 +1 @@
+uid://doah5qceavyym
diff --git a/addons/gdUnit4/src/GdUnitFileAssert.gd b/addons/gdUnit4/src/GdUnitFileAssert.gd
new file mode 100644
index 0000000..771da90
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFileAssert.gd
@@ -0,0 +1,38 @@
+@abstract class_name GdUnitFileAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitFileAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitFileAssert
+
+
+## Verifies that the current value is equal to the given one.
+@abstract func is_equal(expected: Variant) -> GdUnitFileAssert
+
+
+## Verifies that the current value is not equal to expected one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitFileAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitFileAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitFileAssert
+
+
+@abstract func is_file() -> GdUnitFileAssert
+
+
+@abstract func exists() -> GdUnitFileAssert
+
+
+@abstract func is_script() -> GdUnitFileAssert
+
+
+@abstract func contains_exactly(expected_rows :Array) -> GdUnitFileAssert
diff --git a/addons/gdUnit4/src/GdUnitFileAssert.gd.uid b/addons/gdUnit4/src/GdUnitFileAssert.gd.uid
new file mode 100644
index 0000000..2952ee1
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFileAssert.gd.uid
@@ -0,0 +1 @@
+uid://b60x3ixhqf6o7
diff --git a/addons/gdUnit4/src/GdUnitFloatAssert.gd b/addons/gdUnit4/src/GdUnitFloatAssert.gd
new file mode 100644
index 0000000..2695ab0
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFloatAssert.gd
@@ -0,0 +1,75 @@
+## An Assertion Tool to verify float values
+@abstract class_name GdUnitFloatAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitFloatAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitFloatAssert
+
+
+## Verifies that the current value is equal to the given one.
+@abstract func is_equal(expected: Variant) -> GdUnitFloatAssert
+
+
+## Verifies that the current value is not equal to expected one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitFloatAssert
+
+
+## Verifies that the current and expected value are approximately equal.
+@abstract func is_equal_approx(expected: float, approx: float) -> GdUnitFloatAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitFloatAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitFloatAssert
+
+
+## Verifies that the current value is less than the given one.
+@abstract func is_less(expected: float) -> GdUnitFloatAssert
+
+
+## Verifies that the current value is less than or equal the given one.
+@abstract func is_less_equal(expected: float) -> GdUnitFloatAssert
+
+
+## Verifies that the current value is greater than the given one.
+@abstract func is_greater(expected: float) -> GdUnitFloatAssert
+
+
+## Verifies that the current value is greater than or equal the given one.
+@abstract func is_greater_equal(expected: float) -> GdUnitFloatAssert
+
+
+## Verifies that the current value is negative.
+@abstract func is_negative() -> GdUnitFloatAssert
+
+
+## Verifies that the current value is not negative.
+@abstract func is_not_negative() -> GdUnitFloatAssert
+
+
+## Verifies that the current value is equal to zero.
+@abstract func is_zero() -> GdUnitFloatAssert
+
+
+## Verifies that the current value is not equal to zero.
+@abstract func is_not_zero() -> GdUnitFloatAssert
+
+
+## Verifies that the current value is in the given set of values.
+@abstract func is_in(expected: Array) -> GdUnitFloatAssert
+
+
+## Verifies that the current value is not in the given set of values.
+@abstract func is_not_in(expected: Array) -> GdUnitFloatAssert
+
+
+## Verifies that the current value is between the given boundaries (inclusive).
+@abstract func is_between(from: float, to: float) -> GdUnitFloatAssert
diff --git a/addons/gdUnit4/src/GdUnitFloatAssert.gd.uid b/addons/gdUnit4/src/GdUnitFloatAssert.gd.uid
new file mode 100644
index 0000000..f8a9d62
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFloatAssert.gd.uid
@@ -0,0 +1 @@
+uid://c8o2ucj3eairh
diff --git a/addons/gdUnit4/src/GdUnitFuncAssert.gd b/addons/gdUnit4/src/GdUnitFuncAssert.gd
new file mode 100644
index 0000000..e8a49c5
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFuncAssert.gd
@@ -0,0 +1,42 @@
+## An Assertion Tool to verify function callback values
+@abstract class_name GdUnitFuncAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitFuncAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitFuncAssert
+
+
+## Verifies that the current value is equal to the given one.
+@abstract func is_equal(expected: Variant) -> GdUnitFuncAssert
+
+
+## Verifies that the current value is not equal to expected one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitFuncAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitFuncAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitFuncAssert
+
+
+## Verifies that the current value is true.
+@abstract func is_true() -> GdUnitFuncAssert
+
+
+## Verifies that the current value is false.
+@abstract func is_false() -> GdUnitFuncAssert
+
+
+## Sets the timeout in ms to wait the function returnd the expected value, if the time over a failure is emitted.[br]
+## e.g.[br]
+## do wait until 5s the function `is_state` is returns 10 [br]
+## [code]assert_func(instance, "is_state").wait_until(5000).is_equal(10)[/code]
+@abstract func wait_until(timeout: int) -> GdUnitFuncAssert
diff --git a/addons/gdUnit4/src/GdUnitFuncAssert.gd.uid b/addons/gdUnit4/src/GdUnitFuncAssert.gd.uid
new file mode 100644
index 0000000..f181d94
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitFuncAssert.gd.uid
@@ -0,0 +1 @@
+uid://bu7c3ksp8ncrm
diff --git a/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd b/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd
new file mode 100644
index 0000000..01711f9
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd
@@ -0,0 +1,59 @@
+## An assertion tool to verify for Godot runtime errors like assert() and push notifications like push_error().
+@abstract class_name GdUnitGodotErrorAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitGodotErrorAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitGodotErrorAssert
+
+
+## Verifies that the current value is equal to the given one.
+@abstract func is_equal(expected: Variant) -> GdUnitGodotErrorAssert
+
+
+## Verifies that the current value is not equal to expected one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitGodotErrorAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitGodotErrorAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitGodotErrorAssert
+
+
+## Verifies if the executed code runs without any runtime errors
+## Usage:
+## [codeblock]
+## await assert_error(<callable>).is_success()
+## [/codeblock]
+@abstract func is_success() -> GdUnitGodotErrorAssert
+
+
+## Verifies if the executed code runs into a runtime error
+## Usage:
+## [codeblock]
+## await assert_error(<callable>).is_runtime_error(<expected error message>)
+## [/codeblock]
+@abstract func is_runtime_error(expected_error: Variant) -> GdUnitGodotErrorAssert
+
+
+## Verifies if the executed code has a push_warning() used
+## Usage:
+## [codeblock]
+## await assert_error(<callable>).is_push_warning(<expected push warning message>)
+## [/codeblock]
+@abstract func is_push_warning(expected_warning: Variant) -> GdUnitGodotErrorAssert
+
+
+## Verifies if the executed code has a push_error() used
+## Usage:
+## [codeblock]
+## await assert_error(<callable>).is_push_error(<expected push error message>)
+## [/codeblock]
+@abstract func is_push_error(expected_error: Variant) -> GdUnitGodotErrorAssert
diff --git a/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd.uid b/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd.uid
new file mode 100644
index 0000000..8b1c6c7
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitGodotErrorAssert.gd.uid
@@ -0,0 +1 @@
+uid://c72hekdqe2np7
diff --git a/addons/gdUnit4/src/GdUnitIntAssert.gd b/addons/gdUnit4/src/GdUnitIntAssert.gd
new file mode 100644
index 0000000..05eb922
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitIntAssert.gd
@@ -0,0 +1,79 @@
+## An Assertion Tool to verify integer values
+@abstract class_name GdUnitIntAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitIntAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitIntAssert
+
+
+## Verifies that the current value is equal to the given one.
+@abstract func is_equal(expected: Variant) -> GdUnitIntAssert
+
+
+## Verifies that the current value is not equal to expected one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitIntAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitIntAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitIntAssert
+
+
+## Verifies that the current value is less than the given one.
+@abstract func is_less(expected: int) -> GdUnitIntAssert
+
+
+## Verifies that the current value is less than or equal the given one.
+@abstract func is_less_equal(expected: int) -> GdUnitIntAssert
+
+
+## Verifies that the current value is greater than the given one.
+@abstract func is_greater(expected: int) -> GdUnitIntAssert
+
+
+## Verifies that the current value is greater than or equal the given one.
+@abstract func is_greater_equal(expected: int) -> GdUnitIntAssert
+
+
+## Verifies that the current value is even.
+@abstract func is_even() -> GdUnitIntAssert
+
+
+## Verifies that the current value is odd.
+@abstract func is_odd() -> GdUnitIntAssert
+
+
+## Verifies that the current value is negative.
+@abstract func is_negative() -> GdUnitIntAssert
+
+
+## Verifies that the current value is not negative.
+@abstract func is_not_negative() -> GdUnitIntAssert
+
+
+## Verifies that the current value is equal to zero.
+@abstract func is_zero() -> GdUnitIntAssert
+
+
+## Verifies that the current value is not equal to zero.
+@abstract func is_not_zero() -> GdUnitIntAssert
+
+
+## Verifies that the current value is in the given set of values.
+@abstract func is_in(expected: Array) -> GdUnitIntAssert
+
+
+## Verifies that the current value is not in the given set of values.
+@abstract func is_not_in(expected: Array) -> GdUnitIntAssert
+
+
+## Verifies that the current value is between the given boundaries (inclusive).
+@abstract func is_between(from: int, to: int) -> GdUnitIntAssert
diff --git a/addons/gdUnit4/src/GdUnitIntAssert.gd.uid b/addons/gdUnit4/src/GdUnitIntAssert.gd.uid
new file mode 100644
index 0000000..22b6ec0
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitIntAssert.gd.uid
@@ -0,0 +1 @@
+uid://ctx5mjsksw4t8
diff --git a/addons/gdUnit4/src/GdUnitObjectAssert.gd b/addons/gdUnit4/src/GdUnitObjectAssert.gd
new file mode 100644
index 0000000..9d7e76e
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitObjectAssert.gd
@@ -0,0 +1,51 @@
+## An Assertion Tool to verify Object values
+@abstract class_name GdUnitObjectAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitObjectAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitObjectAssert
+
+
+## Verifies that the current value is equal to the given one.
+@abstract func is_equal(expected: Variant) -> GdUnitObjectAssert
+
+
+## Verifies that the current value is not equal to expected one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitObjectAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitObjectAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitObjectAssert
+
+
+## Verifies that the current object is the same as the given one.
+@abstract func is_same(expected: Variant) -> GdUnitObjectAssert
+
+
+## Verifies that the current object is not the same as the given one.
+@abstract func is_not_same(expected: Variant) -> GdUnitObjectAssert
+
+
+## Verifies that the current object is an instance of the given type.
+@abstract func is_instanceof(type: Variant) -> GdUnitObjectAssert
+
+
+## Verifies that the current object is not an instance of the given type.
+@abstract func is_not_instanceof(type: Variant) -> GdUnitObjectAssert
+
+
+## Checks whether the current object inherits from the specified type.
+@abstract func is_inheriting(type: Variant) -> GdUnitObjectAssert
+
+
+## Checks whether the current object does NOT inherit from the specified type.
+@abstract func is_not_inheriting(type: Variant) -> GdUnitObjectAssert
diff --git a/addons/gdUnit4/src/GdUnitObjectAssert.gd.uid b/addons/gdUnit4/src/GdUnitObjectAssert.gd.uid
new file mode 100644
index 0000000..0ad3f3b
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitObjectAssert.gd.uid
@@ -0,0 +1 @@
+uid://chg5kc3ebu05
diff --git a/addons/gdUnit4/src/GdUnitResultAssert.gd b/addons/gdUnit4/src/GdUnitResultAssert.gd
new file mode 100644
index 0000000..01eb880
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitResultAssert.gd
@@ -0,0 +1,51 @@
+## An Assertion Tool to verify Results
+@abstract class_name GdUnitResultAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitResultAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitResultAssert
+
+
+## Verifies that the current value is equal to the given one.
+@abstract func is_equal(expected: Variant) -> GdUnitResultAssert
+
+
+## Verifies that the current value is not equal to expected one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitResultAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitResultAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitResultAssert
+
+
+## Verifies that the result is ends up with empty
+@abstract func is_empty() -> GdUnitResultAssert
+
+
+## Verifies that the result is ends up with success
+@abstract func is_success() -> GdUnitResultAssert
+
+
+## Verifies that the result is ends up with warning
+@abstract func is_warning() -> GdUnitResultAssert
+
+
+## Verifies that the result is ends up with error
+@abstract func is_error() -> GdUnitResultAssert
+
+
+## Verifies that the result contains the given message
+@abstract func contains_message(expected: String) -> GdUnitResultAssert
+
+
+## Verifies that the result contains the given value
+@abstract func is_value(expected: Variant) -> GdUnitResultAssert
diff --git a/addons/gdUnit4/src/GdUnitResultAssert.gd.uid b/addons/gdUnit4/src/GdUnitResultAssert.gd.uid
new file mode 100644
index 0000000..265e4a5
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitResultAssert.gd.uid
@@ -0,0 +1 @@
+uid://3idlr54yows6
diff --git a/addons/gdUnit4/src/GdUnitSceneRunner.gd b/addons/gdUnit4/src/GdUnitSceneRunner.gd
new file mode 100644
index 0000000..9b156f3
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitSceneRunner.gd
@@ -0,0 +1,350 @@
+## The Scene Runner is a tool used for simulating interactions on a scene.
+## With this tool, you can simulate input events such as keyboard or mouse input and/or simulate scene processing over a certain number of frames.
+## This tool is typically used for integration testing a scene.
+@abstract class_name GdUnitSceneRunner
+extends RefCounted
+
+
+## Simulates that an action has been pressed.[br]
+## [member action] : the action e.g. [code]"ui_up"[/code][br]
+## [member event_index] : [url=https://docs.godotengine.org/en/4.4/classes/class_inputeventaction.html#class-inputeventaction-property-event-index]default=-1[/url][br]
+@abstract func simulate_action_pressed(action: String, event_index := -1) -> GdUnitSceneRunner
+
+
+## Simulates that an action is pressed.[br]
+## [member action] : the action e.g. [code]"ui_up"[/code][br]
+## [member event_index] : [url=https://docs.godotengine.org/en/4.4/classes/class_inputeventaction.html#class-inputeventaction-property-event-index]default=-1[/url][br]
+@abstract func simulate_action_press(action: String, event_index := -1) -> GdUnitSceneRunner
+
+
+## Simulates that an action has been released.[br]
+## [member action] : the action e.g. [code]"ui_up"[/code][br]
+## [member event_index] : [url=https://docs.godotengine.org/en/4.4/classes/class_inputeventaction.html#class-inputeventaction-property-event-index]default=-1[/url][br]
+@abstract func simulate_action_release(action: String, event_index := -1) -> GdUnitSceneRunner
+
+
+## Simulates that a key has been pressed.[br]
+## @deprecated: the modifier [b]shift_pressed[/b] and [b]ctrl_pressed[/b] will be removed in v7.0
+## [member key_code] : the key code e.g. [constant KEY_ENTER][br]
+## [codeblock]
+## func test_key_presssed():
+## var runner = scene_runner("res://scenes/simple_scene.tscn")
+## await runner.simulate_key_pressed(KEY_SPACE)
+## [/codeblock]
+@abstract func simulate_key_pressed(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner
+
+
+## Simulates that a key is pressing.[br]
+## @deprecated: the modifier [b]shift_pressed[/b] and [b]ctrl_pressed[/b] will be removed in v7.0[br]See `test_key_shift_and_A_presssing` for example using key combinations
+## [member key_code] : the key code e.g. [constant KEY_ENTER][br]
+## [codeblock]
+## # Do simulate key pressing A
+## func test_key_A_presssing():
+## var runner = scene_runner("res://scenes/simple_scene.tscn")
+## await runner.simulate_key_press(KEY_A)
+##
+##
+## # Do simulate keycombination pressing shift+A
+## func test_key_shift_and_A_presssing():
+## var runner = scene_runner("res://scenes/simple_scene.tscn")
+## runner.simulate_key_press(KEY_SHIFT)
+## runner.simulate_key_press(KEY_A)
+## await _runner.await_input_processed()
+## [/codeblock]
+@abstract func simulate_key_press(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner
+
+
+## Simulates that a key has been released.[br]
+## [member key_code] : the key code e.g. [constant KEY_ENTER][br]
+## [codeblock]
+## # Do simulate releasing key A
+## func test_key_A_releasing():
+## var runner = scene_runner("res://scenes/simple_scene.tscn")
+## await runner.simulate_key_release(KEY_A)
+##
+##
+## # Do simulate keycombination pressing shift+A
+## func test_key_shift_and_A_releasing(():
+## var runner = scene_runner("res://scenes/simple_scene.tscn")
+## runner.simulate_key_release(KEY_SHIFT)
+## runner.simulate_key_release(KEY_A)
+## await _runner.await_input_processed()
+## [/codeblock]
+## @deprecated: the modifier [b]shift_pressed[/b] and [b]ctrl_pressed[/b] will be removed in v7.0[br]See `test_key_shift_and_A_releasing` for example using key combinations
+@abstract func simulate_key_release(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner
+
+
+## Sets the mouse position to the specified vector, provided in pixels and relative to an origin at the upper left corner of the currently focused Window Manager game window.[br]
+## [member position] : The absolute position in pixels as Vector2
+@abstract func set_mouse_position(position: Vector2) -> GdUnitSceneRunner
+
+
+## Returns the mouse's position in this Viewport using the coordinate system of this Viewport.
+@abstract func get_mouse_position() -> Vector2
+
+
+## Gets the current global mouse position of the current window
+@abstract func get_global_mouse_position() -> Vector2
+
+
+## Simulates a mouse moved to final position.[br]
+## [member position] : The final mouse position
+@abstract func simulate_mouse_move(position: Vector2) -> GdUnitSceneRunner
+
+
+## Simulates a mouse move to the relative coordinates (offset).[br]
+## [color=yellow]You must use [b]await[/b] to wait until the simulated mouse movement is complete.[/color][br]
+## [br]
+## [member relative] : The relative position, indicating the mouse position offset.[br]
+## [member time] : The time to move the mouse by the relative position in seconds (default is 1 second).[br]
+## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br]
+## [codeblock]
+## func test_move_mouse():
+## var runner = scene_runner("res://scenes/simple_scene.tscn")
+## await runner.simulate_mouse_move_relative(Vector2(100,100))
+## [/codeblock]
+@abstract func simulate_mouse_move_relative(relative: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner
+
+
+## Simulates a mouse move to the absolute coordinates.[br]
+## [color=yellow]You must use [b]await[/b] to wait until the simulated mouse movement is complete.[/color][br]
+## [br]
+## [member position] : The final position of the mouse.[br]
+## [member time] : The time to move the mouse to the final position in seconds (default is 1 second).[br]
+## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br]
+## [codeblock]
+## func test_move_mouse():
+## var runner = scene_runner("res://scenes/simple_scene.tscn")
+## await runner.simulate_mouse_move_absolute(Vector2(100,100))
+## [/codeblock]
+@abstract func simulate_mouse_move_absolute(position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner
+
+
+## Simulates a mouse button pressed.[br]
+## [member button_index] : The mouse button identifier, one of the [enum MouseButton] or button wheel constants.
+## [member double_click] : Set to true to simulate a double-click
+@abstract func simulate_mouse_button_pressed(button_index: MouseButton, double_click := false) -> GdUnitSceneRunner
+
+
+## Simulates a mouse button press (holding)[br]
+## [member button_index] : The mouse button identifier, one of the [enum MouseButton] or button wheel constants.
+## [member double_click] : Set to true to simulate a double-click
+@abstract func simulate_mouse_button_press(button_index: MouseButton, double_click := false) -> GdUnitSceneRunner
+
+
+## Simulates a mouse button released.[br]
+## [member button_index] : The mouse button identifier, one of the [enum MouseButton] or button wheel constants.
+@abstract func simulate_mouse_button_release(button_index: MouseButton) -> GdUnitSceneRunner
+
+
+## Simulates a screen touch is pressed.[br]
+## [member index] : The touch index in the case of a multi-touch event.[br]
+## [member position] : The position to touch the screen.[br]
+## [member double_tap] : If true, the touch's state is a double tab.
+@abstract func simulate_screen_touch_pressed(index: int, position: Vector2, double_tap := false) -> GdUnitSceneRunner
+
+
+## Simulates a screen touch press without releasing it immediately, effectively simulating a "hold" action.[br]
+## [member index] : The touch index in the case of a multi-touch event.[br]
+## [member position] : The position to touch the screen.[br]
+## [member double_tap] : If true, the touch's state is a double tab.
+@abstract func simulate_screen_touch_press(index: int, position: Vector2, double_tap := false) -> GdUnitSceneRunner
+
+
+## Simulates a screen touch is released.[br]
+## [member index] : The touch index in the case of a multi-touch event.[br]
+## [member double_tap] : If true, the touch's state is a double tab.
+@abstract func simulate_screen_touch_release(index: int, double_tap := false) -> GdUnitSceneRunner
+
+
+## Simulates a touch drag and drop event to a relative position.[br]
+## [color=yellow]You must use [b]await[/b] to wait until the simulated drag&drop is complete.[/color][br]
+## [br]
+## [member index] : The touch index in the case of a multi-touch event.[br]
+## [member relative] : The relative position, indicating the drag&drop position offset.[br]
+## [member time] : The time to move to the relative position in seconds (default is 1 second).[br]
+## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br]
+## [codeblock]
+## func test_touch_drag_drop():
+## var runner = scene_runner("res://scenes/simple_scene.tscn")
+## # start drag at position 50,50
+## runner.simulate_screen_touch_drag_begin(1, Vector2(50, 50))
+## # and drop it at final at 150,50 relative (50,50 + 100,0)
+## await runner.simulate_screen_touch_drag_relative(1, Vector2(100,0))
+## [/codeblock]
+@abstract func simulate_screen_touch_drag_relative(index: int, relative: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner
+
+
+## Simulates a touch screen drop to the absolute coordinates (offset).[br]
+## [color=yellow]You must use [b]await[/b] to wait until the simulated drop is complete.[/color][br]
+## [br]
+## [member index] : The touch index in the case of a multi-touch event.[br]
+## [member position] : The final position, indicating the drop position.[br]
+## [member time] : The time to move to the final position in seconds (default is 1 second).[br]
+## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br]
+## [codeblock]
+## func test_touch_drag_drop():
+## var runner = scene_runner("res://scenes/simple_scene.tscn")
+## # start drag at position 50,50
+## runner.simulate_screen_touch_drag_begin(1, Vector2(50, 50))
+## # and drop it at 100,50
+## await runner.simulate_screen_touch_drag_absolute(1, Vector2(100,50))
+## [/codeblock]
+@abstract func simulate_screen_touch_drag_absolute(index: int, position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner
+
+
+## Simulates a complete drag and drop event from one position to another.[br]
+## This is ideal for testing complex drag-and-drop scenarios that require a specific start and end position.[br]
+## [color=yellow]You must use [b]await[/b] to wait until the simulated drop is complete.[/color][br]
+## [br]
+## [member index] : The touch index in the case of a multi-touch event.[br]
+## [member position] : The drag start position, indicating the drag position.[br]
+## [member drop_position] : The drop position, indicating the drop position.[br]
+## [member time] : The time to move to the final position in seconds (default is 1 second).[br]
+## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br]
+## [codeblock]
+## func test_touch_drag_drop():
+## var runner = scene_runner("res://scenes/simple_scene.tscn")
+## # start drag at position 50,50 and drop it at 100,50
+## await runner.simulate_screen_touch_drag_drop(1, Vector2(50, 50), Vector2(100,50))
+## [/codeblock]
+@abstract func simulate_screen_touch_drag_drop(index: int, position: Vector2, drop_position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner
+
+
+## Simulates a touch screen drag event to given position.[br]
+## [member index] : The touch index in the case of a multi-touch event.[br]
+## [member position] : The drag start position, indicating the drag position.[br]
+@abstract func simulate_screen_touch_drag(index: int, position: Vector2) -> GdUnitSceneRunner
+
+
+## Returns the actual position of the touchscreen drag position by given index.
+## [member index] : The touch index in the case of a multi-touch event.[br]
+@abstract func get_screen_touch_drag_position(index: int) -> Vector2
+
+
+## Sets how fast or slow the scene simulation is processed (clock ticks versus the real).[br]
+## It defaults to 1.0. A value of 2.0 means the game moves twice as fast as real life,
+## whilst a value of 0.5 means the game moves at half the regular speed.
+## [member time_factor] : A float representing the simulation speed.[br]
+## - Default is 1.0, meaning the simulation runs at normal speed.[br]
+## - A value of 2.0 means the simulation runs twice as fast as real time.[br]
+## - A value of 0.5 means the simulation runs at half the regular speed.[br]
+@abstract func set_time_factor(time_factor: float = 1.0) -> GdUnitSceneRunner
+
+
+## Simulates scene processing for a certain number of frames.[br]
+## [member frames] : amount of frames to process[br]
+## [member delta_milli] : the time delta between a frame in milliseconds
+@abstract func simulate_frames(frames: int, delta_milli: int = -1) -> GdUnitSceneRunner
+
+
+## Simulates scene processing until the given signal is emitted by the scene.[br]
+## [member signal_name] : the signal to stop the simulation[br]
+## [member args] : optional signal arguments to be matched for stop[br]
+@abstract func simulate_until_signal(signal_name: String, ...args: Array) -> GdUnitSceneRunner
+
+
+## Simulates scene processing until the given signal is emitted by the given object.[br]
+## [member source] : the object that should emit the signal[br]
+## [member signal_name] : the signal to stop the simulation[br]
+## [member args] : optional signal arguments to be matched for stop
+@abstract func simulate_until_object_signal(source: Object, signal_name: String, ...args: Array) -> GdUnitSceneRunner
+
+
+## Waits for all input events to be processed by flushing any buffered input events
+## and then awaiting a full cycle of both the process and physics frames.[br]
+## [br]
+## This is typically used to ensure that any simulated or queued inputs are fully
+## processed before proceeding with the next steps in the scene.[br]
+## It's essential for reliable input simulation or when synchronizing logic based
+## on inputs.[br]
+##
+## Usage Example:
+## [codeblock]
+## await await_input_processed() # Ensure all inputs are processed before continuing
+## [/codeblock]
+@abstract func await_input_processed() -> void
+
+
+## The await_func function pauses execution until a specified function in the scene returns a value.[br]
+## It returns a [GdUnitFuncAssert], which provides a suite of assertion methods to verify the returned value.[br]
+## [member func_name] : The name of the function to wait for.[br]
+## [member args] : Optional function arguments
+## [br]
+## Usage Example:
+## [codeblock]
+## # Waits for 'calculate_score' function and verifies the result is equal to 100.
+## await_func("calculate_score").is_equal(100)
+## [/codeblock]
+@abstract func await_func(func_name: String, ...args: Array) -> GdUnitFuncAssert
+
+
+## The await_func_on function extends the functionality of await_func by allowing you to specify a source node within the scene.[br]
+## It waits for a specified function on that node to return a value and returns a [GdUnitFuncAssert] object for assertions.[br]
+## [member source] : The object where implements the function.[br]
+## [member func_name] : The name of the function to wait for.[br]
+## [member args] : optional function arguments
+## [br]
+## Usage Example:
+## [codeblock]
+## # Waits for 'calculate_score' function and verifies the result is equal to 100.
+## var my_instance := ScoreCalculator.new()
+## await_func(my_instance, "calculate_score").is_equal(100)
+## [/codeblock]
+@abstract func await_func_on(source: Object, func_name: String, ...args: Array) -> GdUnitFuncAssert
+
+
+## Waits for the specified signal to be emitted by the scene. If the signal is not emitted within the given timeout, the operation fails.[br]
+## [member signal_name] : The name of the signal to wait for[br]
+## [member args] : The signal arguments as an array[br]
+## [member timeout] : The maximum duration (in milliseconds) to wait for the signal to be emitted before failing
+@abstract func await_signal(signal_name: String, args := [], timeout := 2000 ) -> void
+
+
+## Waits for the specified signal to be emitted by a particular source node. If the signal is not emitted within the given timeout, the operation fails.[br]
+## [member source] : the object from which the signal is emitted[br]
+## [member signal_name] : The name of the signal to wait for[br]
+## [member args] : The signal arguments as an array[br]
+## [member timeout] : tThe maximum duration (in milliseconds) to wait for the signal to be emitted before failing
+@abstract func await_signal_on(source: Object, signal_name: String, args := [], timeout := 2000 ) -> void
+
+
+## Restores the scene window to a windowed mode and brings it to the foreground.[br]
+## This ensures that the scene is visible and active during testing, making it easier to observe and interact with.
+@abstract func move_window_to_foreground() -> GdUnitSceneRunner
+
+
+## Minimizes the scene window to a windowed mode and brings it to the background.[br]
+## This ensures that the scene is hidden during testing.
+@abstract func move_window_to_background() -> GdUnitSceneRunner
+
+
+## Return the current value of the property with the name <name>.[br]
+## [member name] : name of property[br]
+## [member return] : the value of the property
+@abstract func get_property(name: String) -> Variant
+
+
+## Set the value <value> of the property with the name <name>.[br]
+## [member name] : name of property[br]
+## [member value] : value of property[br]
+## [member return] : true|false depending on valid property name.
+@abstract func set_property(name: String, value: Variant) -> bool
+
+
+## executes the function specified by <name> in the scene and returns the result.[br]
+## [member name] : the name of the function to execute[br]
+## [member args] : optional function arguments[br]
+## [member return] : the function result
+@abstract func invoke(name: String, ...args: Array) -> Variant
+
+
+## Searches for the specified node with the name in the current scene and returns it, otherwise null.[br]
+## [member name] : the name of the node to find[br]
+## [member recursive] : enables/disables seraching recursive[br]
+## [member return] : the node if find otherwise null
+@abstract func find_child(name: String, recursive: bool = true, owned: bool = false) -> Node
+
+
+## Access to current running scene
+@abstract func scene() -> Node
diff --git a/addons/gdUnit4/src/GdUnitSceneRunner.gd.uid b/addons/gdUnit4/src/GdUnitSceneRunner.gd.uid
new file mode 100644
index 0000000..a150947
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitSceneRunner.gd.uid
@@ -0,0 +1 @@
+uid://vtg8n735n6xr
diff --git a/addons/gdUnit4/src/GdUnitSignalAssert.gd b/addons/gdUnit4/src/GdUnitSignalAssert.gd
new file mode 100644
index 0000000..2c3d39b
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitSignalAssert.gd
@@ -0,0 +1,158 @@
+## An Assertion Tool to verify for emitted signals until a waiting time
+@abstract class_name GdUnitSignalAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitSignalAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitSignalAssert
+
+
+## Verifies that the current value is equal to the given one.
+@abstract func is_equal(expected: Variant) -> GdUnitSignalAssert
+
+
+## Verifies that the current value is not equal to expected one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitSignalAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitSignalAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitSignalAssert
+
+
+## Verifies that the specified signal is emitted with the expected arguments.[br]
+##
+## This assertion waits for a signal to be emitted from the object under test and
+## validates that it was emitted with the correct arguments. The function supports
+## both typed signals (Signal type) and string-based signal names for flexibility
+## in different testing scenarios.[br]
+## [br]
+## [b]Parameters:[/b][br]
+## [param signal_name]: The signal to monitor. Can be either:[br]
+## • A [Signal] reference (recommended for type safety)[br]
+## • A [String] with the signal name
+## [param signal_args]: Optional expected signal arguments.[br]
+## When provided, verifies the signal was emitted with exactly these values.[br]
+## [br]
+## [b]Returns:[/b][br]
+## [GdUnitSignalAssert] - Returns self for method chaining.[br]
+## [br]
+## [b]Examples:[/b]
+## [codeblock]
+## signal signal_a(value: int)
+## signal signal_b(name: String, count: int)
+##
+## # Wait for signal emission without checking arguments
+## # Using Signal reference (type-safe)
+## await assert_signal(instance).is_emitted(signal_a)
+## # Using string name (dynamic)
+## await assert_signal(instance).is_emitted("signal_a")
+##
+## # Wait for signal emission with specific argument
+## await assert_signal(instance).is_emitted(signal_a, 10)
+##
+## # Wait for signal with multiple arguments
+## await assert_signal(instance).is_emitted(signal_b, "test", 42)
+##
+## # Wait max 500ms for signal with argument 10
+## await assert_signal(instance).wait_until(500).is_emitted(signal_a, 10)
+## [/codeblock]
+## [br]
+## [b]Note:[/b] This is an async operation - use [code]await[/code] when calling.[br]
+## The assertion fails if the signal is not emitted within the timeout period.
+@abstract func is_emitted(signal_name: Variant, ...signal_args: Array) -> GdUnitSignalAssert
+
+
+## Verifies that the specified signal is NOT emitted with the expected arguments.[br]
+##
+## This assertion waits for a specified time period and validates that a signal
+## was not emitted with the given arguments. Useful for ensuring certain conditions
+## don't trigger unwanted signals or for verifying signal filtering logic.[br]
+## [br]
+## [b]Parameters:[/b][br]
+## [param signal_name]: The signal to monitor. Can be either:[br]
+## • A [Signal] reference (recommended for type safety)[br]
+## • A [String] with the signal name
+## [param signal_args]: Optional expected signal arguments.[br]
+## When provided, verifies the signal was not emitted with these specific values.[br]
+## If omitted, verifies the signal was not emitted at all.[br]
+## [br]
+## [b]Returns:[/b][br]
+## [GdUnitSignalAssert] - Returns self for method chaining.[br]
+## [br]
+## [b]Examples:[/b]
+## [codeblock]
+## signal signal_a(value: int)
+## signal signal_b(name: String, count: int)
+##
+## # Verify signal is not emitted at all (without checking arguments)
+## await assert_signal(instance).wait_until(500).is_not_emitted(signal_a)
+## await assert_signal(instance).wait_until(500).is_not_emitted("signal_a")
+##
+## # Verify signal is not emitted with specific argument
+## await assert_signal(instance).wait_until(500).is_not_emitted(signal_a, 10)
+##
+## # Verify signal is not emitted with multiple arguments
+## await assert_signal(instance).wait_until(500).is_not_emitted(signal_b, "test", 42)
+##
+## # Can be emitted with different arguments (this passes)
+## instance.emit_signal("signal_a", 20) # Emits with 20, not 10
+## await assert_signal(instance).wait_until(500).is_not_emitted(signal_a, 10)
+## [/codeblock]
+## [br]
+## [b]Note:[/b] This is an async operation - use [code]await[/code] when calling.[br]
+## The assertion fails if the signal IS emitted with the specified arguments within the timeout period.
+@abstract func is_not_emitted(signal_name: Variant, ...signal_args: Array) -> GdUnitSignalAssert
+
+
+## Verifies that the specified signal exists on the emitter object.[br]
+##
+## This assertion checks if a signal is defined on the object under test,
+## regardless of whether it has been emitted. Useful for validating that
+## objects have the expected signals before testing their emission.[br]
+## [br]
+## [b]Parameters:[/b][br]
+## [param signal_name]: The signal to check. Can be either:[br]
+## • A [Signal] reference (recommended for type safety)[br]
+## • A [String] with the signal name
+## [br]
+## [b]Returns:[/b][br]
+## [GdUnitSignalAssert] - Returns self for method chaining.[br]
+## [br]
+## [b]Examples:[/b]
+## [codeblock]
+## signal my_signal(value: int)
+## signal another_signal()
+##
+## # Verify signal exists using Signal reference
+## assert_signal(instance).is_signal_exists(my_signal)
+##
+## # Verify signal exists using string name
+## assert_signal(instance).is_signal_exists("my_signal")
+##
+## # Chain with other assertions
+## assert_signal(instance) \
+## .is_signal_exists(my_signal) \
+## .is_emitted(my_signal, 42)
+##
+## [/codeblock]
+## [br]
+## [b]Note:[/b] This only checks signal definition, not emission.[br]
+## The assertion fails if the signal is not defined on the object.
+@abstract func is_signal_exists(signal_name: Variant) -> GdUnitSignalAssert
+
+
+## Sets the assert signal timeout in ms, if the time over a failure is reported.[br]
+## Example:
+## [codeblock]
+## do wait until 5s the instance has emitted the signal `signal_a`[br]
+## assert_signal(instance).wait_until(5000).is_emitted("signal_a")
+## [/codeblock]
+@abstract func wait_until(timeout: int) -> GdUnitSignalAssert
diff --git a/addons/gdUnit4/src/GdUnitSignalAssert.gd.uid b/addons/gdUnit4/src/GdUnitSignalAssert.gd.uid
new file mode 100644
index 0000000..df6df39
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitSignalAssert.gd.uid
@@ -0,0 +1 @@
+uid://bmdasvsfnvbl8
diff --git a/addons/gdUnit4/src/GdUnitStringAssert.gd b/addons/gdUnit4/src/GdUnitStringAssert.gd
new file mode 100644
index 0000000..2de698b
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitStringAssert.gd
@@ -0,0 +1,71 @@
+## An Assertion Tool to verify String values
+@abstract class_name GdUnitStringAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitStringAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitStringAssert
+
+
+## Verifies that the current value is equal to the given one.
+@abstract func is_equal(expected: Variant) -> GdUnitStringAssert
+
+
+## Verifies that the current String is equal to the given one, ignoring case considerations.
+@abstract func is_equal_ignoring_case(expected: Variant) -> GdUnitStringAssert
+
+
+## Verifies that the current value is not equal to expected one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitStringAssert
+
+
+## Verifies that the current String is not equal to the given one, ignoring case considerations.
+@abstract func is_not_equal_ignoring_case(expected: Variant) -> GdUnitStringAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitStringAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitStringAssert
+
+
+## Verifies that the current String is empty, it has a length of 0.
+@abstract func is_empty() -> GdUnitStringAssert
+
+
+## Verifies that the current String is not empty, it has a length of minimum 1.
+@abstract func is_not_empty() -> GdUnitStringAssert
+
+
+## Verifies that the current String contains the given String.
+@abstract func contains(expected: String) -> GdUnitStringAssert
+
+
+## Verifies that the current String does not contain the given String.
+@abstract func not_contains(expected: String) -> GdUnitStringAssert
+
+
+## Verifies that the current String does not contain the given String, ignoring case considerations.
+@abstract func contains_ignoring_case(expected: String) -> GdUnitStringAssert
+
+
+## Verifies that the current String does not contain the given String, ignoring case considerations.
+@abstract func not_contains_ignoring_case(expected: String) -> GdUnitStringAssert
+
+
+## Verifies that the current String starts with the given prefix.
+@abstract func starts_with(expected: String) -> GdUnitStringAssert
+
+
+## Verifies that the current String ends with the given suffix.
+@abstract func ends_with(expected: String) -> GdUnitStringAssert
+
+
+## Verifies that the current String has the expected length by used comparator.
+@abstract func has_length(length: int, comparator: int = Comparator.EQUAL) -> GdUnitStringAssert
diff --git a/addons/gdUnit4/src/GdUnitStringAssert.gd.uid b/addons/gdUnit4/src/GdUnitStringAssert.gd.uid
new file mode 100644
index 0000000..0487c05
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitStringAssert.gd.uid
@@ -0,0 +1 @@
+uid://bp8o13nmu5j3i
diff --git a/addons/gdUnit4/src/GdUnitTestSuite.gd b/addons/gdUnit4/src/GdUnitTestSuite.gd
new file mode 100644
index 0000000..5cf269f
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitTestSuite.gd
@@ -0,0 +1,746 @@
+## The main class for all GdUnit test suites[br]
+## This class is the main class to implement your unit tests[br]
+## You have to extend and implement your test cases as described[br]
+## e.g MyTests.gd [br]
+## [codeblock]
+## extends GdUnitTestSuite
+## # testcase
+## func test_case_a():
+## assert_that("value").is_equal("value")
+## [/codeblock]
+## @tutorial: https://mikeschulze.github.io/gdUnit4/faq/test-suite/
+
+@icon("res://addons/gdUnit4/src/ui/settings/logo.png")
+class_name GdUnitTestSuite
+extends Node
+
+
+### internal runtime variables that must not be overwritten!!!
+@warning_ignore("unused_private_class_variable")
+var __is_skipped := false
+@warning_ignore("unused_private_class_variable")
+var __skip_reason := "Unknow."
+var __active_test_case: String
+var __awaiter := __gdunit_awaiter()
+
+
+### We now load all used asserts and tool scripts into the cache according to the principle of "lazy loading"
+### in order to noticeably reduce the loading time of the test suite.
+# We go this hard way to increase the loading performance to avoid reparsing all the used scripts
+# for more detailed info -> https://github.com/godotengine/godot/issues/67400
+func __lazy_load(script_path: String) -> GDScript:
+ return GdUnitAssertions.__lazy_load(script_path)
+
+
+func __gdunit_assert() -> GDScript:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd")
+
+
+func __gdunit_tools() -> GDScript:
+ return __lazy_load("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+
+func __gdunit_file_access() -> GDScript:
+ return __lazy_load("res://addons/gdUnit4/src/core/GdUnitFileAccess.gd")
+
+
+func __gdunit_awaiter() -> Object:
+ return __lazy_load("res://addons/gdUnit4/src/GdUnitAwaiter.gd").new()
+
+
+func __gdunit_argument_matchers() -> GDScript:
+ return __lazy_load("res://addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd")
+
+
+func __gdunit_object_interactions() -> GDScript:
+ return __lazy_load("res://addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd")
+
+
+## This function is called before a test suite starts[br]
+## You can overwrite to prepare test data or initalizize necessary variables
+func before() -> void:
+ pass
+
+
+## This function is called at least when a test suite is finished[br]
+## You can overwrite to cleanup data created during test running
+func after() -> void:
+ pass
+
+
+## This function is called before a test case starts[br]
+## You can overwrite to prepare test case specific data
+func before_test() -> void:
+ pass
+
+
+## This function is called after the test case is finished[br]
+## You can overwrite to cleanup your test case specific data
+func after_test() -> void:
+ pass
+
+
+func is_failure() -> bool:
+ return Engine.get_meta("GD_TEST_FAILURE") if Engine.has_meta("GD_TEST_FAILURE") else false
+
+
+func set_active_test_case(test_case: String) -> void:
+ __active_test_case = test_case
+
+
+# === Tools ====================================================================
+# Mapps Godot error number to a readable error message. See at ERROR
+# https://docs.godotengine.org/de/stable/classes/class_@globalscope.html#enum-globalscope-error
+func error_as_string(error_number: int) -> String:
+ return error_string(error_number)
+
+
+## A litle helper to auto freeing your created objects after test execution
+func auto_free(obj: Variant) -> Variant:
+ var execution_context := GdUnitThreadManager.get_current_context().get_execution_context()
+
+ assert(execution_context != null, "INTERNAL ERROR: The current execution_context is null! Please report this as bug.")
+ return execution_context.register_auto_free(obj)
+
+
+@warning_ignore("native_method_override")
+func add_child(node: Node, force_readable_name := false, internal := Node.INTERNAL_MODE_DISABLED) -> void:
+ super.add_child(node, force_readable_name, internal)
+ var execution_context := GdUnitThreadManager.get_current_context().get_execution_context()
+ if execution_context != null:
+ execution_context.orphan_monitor_start()
+
+
+## Discard the error message triggered by a timeout (interruption).[br]
+## By default, an interrupted test is reported as an error.[br]
+## This function allows you to change the message to Success when an interrupted error is reported.
+func discard_error_interupted_by_timeout() -> void:
+ @warning_ignore("unsafe_method_access")
+ __gdunit_tools().register_expect_interupted_by_timeout(self, __active_test_case)
+
+
+## Creates a new directory under the temporary directory *user://tmp*[br]
+## Useful for storing data during test execution. [br]
+## The directory is automatically deleted after test suite execution
+func create_temp_dir(relative_path: String) -> String:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_file_access().create_temp_dir(relative_path)
+
+
+## Deletes the temporary base directory[br]
+## Is called automatically after each execution of the test suite
+func clean_temp_dir() -> void:
+ @warning_ignore("unsafe_method_access")
+ __gdunit_file_access().clear_tmp()
+
+
+## Creates a new file under the temporary directory *user://tmp* + <relative_path>[br]
+## with given name <file_name> and given file <mode> (default = File.WRITE)[br]
+## If success the returned File is automatically closed after the execution of the test suite
+func create_temp_file(relative_path: String, file_name: String, mode := FileAccess.WRITE) -> FileAccess:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_file_access().create_temp_file(relative_path, file_name, mode)
+
+
+## Reads a resource by given path <resource_path> into a PackedStringArray.
+func resource_as_array(resource_path: String) -> PackedStringArray:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_file_access().resource_as_array(resource_path)
+
+
+## Reads a resource by given path <resource_path> and returned the content as String.
+func resource_as_string(resource_path: String) -> String:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_file_access().resource_as_string(resource_path)
+
+
+## Reads a resource by given path <resource_path> and return Variand translated by str_to_var
+func resource_as_var(resource_path: String) -> Variant:
+ @warning_ignore("unsafe_method_access", "unsafe_cast")
+ return str_to_var(__gdunit_file_access().resource_as_string(resource_path) as String)
+
+
+## Waits for given signal to be emitted by <source> until a specified timeout to fail[br]
+## source: the object from which the signal is emitted[br]
+## signal_name: signal name[br]
+## args: the expected signal arguments as an array[br]
+## timeout: the timeout in ms, default is set to 2000ms
+func await_signal_on(source: Object, signal_name: String, args: Array = [], timeout: int = 2000) -> Variant:
+ @warning_ignore("unsafe_method_access")
+ return await __awaiter.await_signal_on(source, signal_name, args, timeout)
+
+
+## Waits until the next idle frame
+func await_idle_frame() -> void:
+ @warning_ignore("unsafe_method_access")
+ await __awaiter.await_idle_frame()
+
+
+## Waits for a given amount of milliseconds[br]
+## example:[br]
+## [codeblock]
+## # waits for 100ms
+## await await_millis(myNode, 100).completed
+## [/codeblock][br]
+## use this waiter and not `await get_tree().create_timer().timeout to prevent errors when a test case is timed out
+func await_millis(timeout: int) -> void:
+ @warning_ignore("unsafe_method_access")
+ await __awaiter.await_millis(timeout)
+
+
+## Collects detailed information about orphaned nodes for debugging purposes.[br]
+##
+## This function gathers comprehensive details about nodes that remain in memory
+## after test execution (orphans). It provides debugging information to help
+## identify the source of memory leaks in tests. Must be manually called in
+## tests when orphan nodes are detected.[br]
+## [br]
+## [b]When to Use:[/b][br]
+## - When GdUnit4 reports orphan nodes after test execution[br]
+## - For debugging memory leaks in test scenarios[br]
+## - To get detailed information about unreleased nodes[br]
+## [br]
+## [b]Usage Pattern:[/b][br]
+## Add this call at the end of tests that are suspected to create orphans,
+## or when the test runner reports orphan detection.[br]
+## [br]
+## [b]Examples:[/b]
+## [codeblock]
+## func test_scene_management():
+## # Test code that might create orphan nodes
+## var scene = preload("res://TestScene.tscn").instantiate()
+## add_child(scene)
+##
+## # Do test operations
+## scene.some_method()
+##
+## # Clean up (but might miss some nodes)
+## scene.queue_free()
+##
+## # Collect orphan details if any are detected
+## collect_orphan_node_details()
+## [/codeblock]
+## [br]
+## [b]Note:[/b] This is a debugging utility function that should be removed
+## or commented out once orphan issues are resolved.
+func collect_orphan_node_details() -> void:
+ GdUnitThreadManager.get_current_context().get_execution_context().orphan_monitor_collect()
+
+
+## Creates a new scene runner to allow simulate interactions checked a scene.[br]
+## The runner will manage the scene instance and release after the runner is released[br]
+## example:[br]
+## [codeblock]
+## # creates a runner by using a instanciated scene
+## var scene = load("res://foo/my_scne.tscn").instantiate()
+## var runner := scene_runner(scene)
+##
+## # or simply creates a runner by using the scene resource path
+## var runner := scene_runner("res://foo/my_scne.tscn")
+## [/codeblock]
+func scene_runner(scene: Variant, verbose := false) -> GdUnitSceneRunner:
+ return auto_free(__lazy_load("res://addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd").new(scene, verbose))
+
+
+# === Mocking & Spy ===========================================================
+
+## do return a default value for primitive types or null
+const RETURN_DEFAULTS = GdUnitMock.RETURN_DEFAULTS
+## do call the real implementation
+const CALL_REAL_FUNC = GdUnitMock.CALL_REAL_FUNC
+## do return a default value for primitive types and a fully mocked value for Object types
+## builds full deep mocked object
+const RETURN_DEEP_STUB = GdUnitMock.RETURN_DEEP_STUB
+
+
+## Creates a mock for given class name
+func mock(clazz: Variant, mock_mode := RETURN_DEFAULTS) -> Variant:
+ @warning_ignore("unsafe_method_access")
+ return __lazy_load("res://addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd").build(clazz, mock_mode)
+
+
+## Creates a spy checked given object instance
+func spy(instance: Variant) -> Variant:
+ @warning_ignore("unsafe_method_access")
+ return __lazy_load("res://addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd").build(instance)
+
+
+## Configures a return value for the specified function and used arguments.[br]
+## [b]Example:
+## [codeblock]
+## # overrides the return value of myMock.is_selected() to false
+## do_return(false).on(myMock).is_selected()
+## [/codeblock]
+func do_return(value: Variant) -> GdUnitMock:
+ return GdUnitMock.new(value)
+
+
+## Verifies certain behavior happened at least once or exact number of times
+func verify(obj: Variant, times := 1) -> Variant:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_object_interactions().verify(obj, times)
+
+
+## Verifies no interactions is happen checked this mock or spy
+func verify_no_interactions(obj: Variant) -> GdUnitAssert:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_object_interactions().verify_no_interactions(obj)
+
+
+## Verifies the given mock or spy has any unverified interaction.
+func verify_no_more_interactions(obj: Variant) -> GdUnitAssert:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_object_interactions().verify_no_more_interactions(obj)
+
+
+## Resets the saved function call counters checked a mock or spy
+func reset(obj: Variant) -> void:
+ @warning_ignore("unsafe_method_access")
+ __gdunit_object_interactions().reset(obj)
+
+
+## Starts monitoring the specified source to collect all transmitted signals.[br]
+## The collected signals can then be checked with 'assert_signal'.[br]
+## By default, the specified source is automatically released when the test ends.
+## You can control this behavior by setting auto_free to false if you do not want the source to be automatically freed.[br]
+## Usage:
+## [codeblock]
+## var emitter := monitor_signals(MyEmitter.new())
+## # call the function to send the signal
+## emitter.do_it()
+## # verify the signial is emitted
+## await assert_signal(emitter).is_emitted('my_signal')
+## [/codeblock]
+func monitor_signals(source: Object, _auto_free := true) -> Object:
+ @warning_ignore("unsafe_method_access")
+ __lazy_load("res://addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd")\
+ .get_current_context()\
+ .get_signal_collector()\
+ .register_emitter(source, true) # force recreate to start with a fresh monitoring
+ return auto_free(source) if _auto_free else source
+
+
+# === Argument matchers ========================================================
+## Argument matcher to match any argument
+func any() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().any()
+
+
+## Argument matcher to match any boolean value
+func any_bool() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_BOOL)
+
+
+## Argument matcher to match any integer value
+func any_int() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_INT)
+
+
+## Argument matcher to match any float value
+func any_float() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_FLOAT)
+
+
+## Argument matcher to match any String value
+func any_string() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_STRING)
+
+
+## Argument matcher to match any Color value
+func any_color() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_COLOR)
+
+
+## Argument matcher to match any Vector typed value
+func any_vector() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_types([
+ TYPE_VECTOR2,
+ TYPE_VECTOR2I,
+ TYPE_VECTOR3,
+ TYPE_VECTOR3I,
+ TYPE_VECTOR4,
+ TYPE_VECTOR4I,
+ ])
+
+
+## Argument matcher to match any Vector2 value
+func any_vector2() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_VECTOR2)
+
+
+## Argument matcher to match any Vector2i value
+func any_vector2i() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_VECTOR2I)
+
+
+## Argument matcher to match any Vector3 value
+func any_vector3() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_VECTOR3)
+
+
+## Argument matcher to match any Vector3i value
+func any_vector3i() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_VECTOR3I)
+
+
+## Argument matcher to match any Vector4 value
+func any_vector4() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_VECTOR4)
+
+
+## Argument matcher to match any Vector4i value
+func any_vector4i() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_VECTOR4I)
+
+
+## Argument matcher to match any Rect2 value
+func any_rect2() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_RECT2)
+
+
+## Argument matcher to match any Plane value
+func any_plane() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_PLANE)
+
+
+## Argument matcher to match any Quaternion value
+func any_quat() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_QUATERNION)
+
+
+## Argument matcher to match any AABB value
+func any_aabb() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_AABB)
+
+
+## Argument matcher to match any Basis value
+func any_basis() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_BASIS)
+
+
+## Argument matcher to match any Transform2D value
+func any_transform_2d() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_TRANSFORM2D)
+
+
+## Argument matcher to match any Transform3D value
+func any_transform_3d() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_TRANSFORM3D)
+
+
+## Argument matcher to match any NodePath value
+func any_node_path() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_NODE_PATH)
+
+
+## Argument matcher to match any RID value
+func any_rid() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_RID)
+
+
+## Argument matcher to match any Object value
+func any_object() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_OBJECT)
+
+
+## Argument matcher to match any Dictionary value
+func any_dictionary() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_DICTIONARY)
+
+
+## Argument matcher to match any Array value
+func any_array() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_ARRAY)
+
+
+## Argument matcher to match any PackedByteArray value
+func any_packed_byte_array() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_PACKED_BYTE_ARRAY)
+
+
+## Argument matcher to match any PackedInt32Array value
+func any_packed_int32_array() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_PACKED_INT32_ARRAY)
+
+
+## Argument matcher to match any PackedInt64Array value
+func any_packed_int64_array() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_PACKED_INT64_ARRAY)
+
+
+## Argument matcher to match any PackedFloat32Array value
+func any_packed_float32_array() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_PACKED_FLOAT32_ARRAY)
+
+
+## Argument matcher to match any PackedFloat64Array value
+func any_packed_float64_array() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_PACKED_FLOAT64_ARRAY)
+
+
+## Argument matcher to match any PackedStringArray value
+func any_packed_string_array() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_PACKED_STRING_ARRAY)
+
+
+## Argument matcher to match any PackedVector2Array value
+func any_packed_vector2_array() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_PACKED_VECTOR2_ARRAY)
+
+
+## Argument matcher to match any PackedVector3Array value
+func any_packed_vector3_array() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_PACKED_VECTOR3_ARRAY)
+
+
+## Argument matcher to match any PackedColorArray value
+func any_packed_color_array() -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().by_type(TYPE_PACKED_COLOR_ARRAY)
+
+
+## Argument matcher to match any instance of given class
+func any_class(clazz :Object) -> GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ return __gdunit_argument_matchers().any_class(clazz)
+
+
+# === value extract utils ======================================================
+## Builds an extractor by given function name and optional arguments
+func extr(func_name: String, args := Array()) -> GdUnitValueExtractor:
+ return __lazy_load("res://addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd").new(func_name, args)
+
+
+## Creates a GdUnitTuple from the provided arguments for use in test assertions.
+## [br]
+## This is the primary helper function for creating tuples in GdUnit4 tests.
+## It provides a convenient way to group multiple expected values when using
+## [method extractv] assertions. The function enforces that tuples must contain
+## at least two values, as single-value extractions don't require tuple grouping.
+## [br]
+## [b]Parameters:[/b] [br]
+## - [code]...args[/code]: Variable number of arguments (minimum 2) to group into a tuple.
+## Each argument represents a value to be compared in assertions.
+## [br]
+## [b]Returns:[/b] [br]
+## A [GdUnitTuple] containing the provided values, or an empty tuple if fewer than
+## 2 arguments are provided (with an error message).
+## [br]
+## [b]Error Handling:[/b] [br]
+## [codeblock]
+## # This will push an error and return empty tuple
+## var invalid = tuple("single_value") # Error: requires at least 2 arguments
+## [br]
+## # Correct usage - minimum 2 arguments
+## var valid = tuple("name", "value")
+## var valid_multi = tuple(1, 2, 3, 4, 5) # Can have many values
+## [/codeblock]
+func tuple(...args: Array) -> GdUnitTuple:
+ if args.size() < 2:
+ push_error("Tuple requires at least two arguments.")
+ return GdUnitTuple.new()
+ return GdUnitTuple.new.callv(args)
+
+
+# === Asserts ==================================================================
+
+## The common assertion tool to verify values.
+## It checks the given value by type to fit to the best assert
+func assert_that(current: Variant) -> GdUnitAssert:
+ match typeof(current):
+ TYPE_BOOL:
+ return assert_bool(current)
+ TYPE_INT:
+ return assert_int(current)
+ TYPE_FLOAT:
+ return assert_float(current)
+ TYPE_STRING:
+ return assert_str(current)
+ TYPE_VECTOR2, TYPE_VECTOR2I, TYPE_VECTOR3, TYPE_VECTOR3I, TYPE_VECTOR4, TYPE_VECTOR4I:
+ return assert_vector(current, false)
+ TYPE_DICTIONARY:
+ return assert_dict(current)
+ TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY,\
+ TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY,\
+ TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_COLOR_ARRAY:
+ return assert_array(current, false)
+ TYPE_OBJECT, TYPE_NIL:
+ return assert_object(current)
+ _:
+ return __gdunit_assert().new(current)
+
+
+## An assertion tool to verify boolean values.
+func assert_bool(current: Variant) -> GdUnitBoolAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd").new(current)
+
+
+## An assertion tool to verify String values.
+func assert_str(current: Variant) -> GdUnitStringAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd").new(current)
+
+
+## An assertion tool to verify integer values.
+func assert_int(current: Variant) -> GdUnitIntAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd").new(current)
+
+
+## An assertion tool to verify float values.
+func assert_float(current: Variant) -> GdUnitFloatAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd").new(current)
+
+
+## An assertion tool to verify Vector values.[br]
+## This assertion supports all vector types.[br]
+## Usage:
+## [codeblock]
+## assert_vector(Vector2(1.2, 1.000001)).is_equal(Vector2(1.2, 1.000001))
+## [/codeblock]
+func assert_vector(current: Variant, type_check := true) -> GdUnitVectorAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd").new(current, type_check)
+
+
+## An assertion tool to verify arrays.
+func assert_array(current: Variant, type_check := true) -> GdUnitArrayAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd").new(current, type_check)
+
+
+## An assertion tool to verify dictionaries.
+func assert_dict(current: Variant) -> GdUnitDictionaryAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd").new(current)
+
+
+## An assertion tool to verify FileAccess.
+func assert_file(current: Variant) -> GdUnitFileAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd").new(current)
+
+
+## An assertion tool to verify Objects.
+func assert_object(current: Variant) -> GdUnitObjectAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd").new(current)
+
+
+func assert_result(current: Variant) -> GdUnitResultAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd").new(current)
+
+
+## An assertion tool that waits until a certain time for an expected function return value
+func assert_func(instance: Object, func_name: String, args := Array()) -> GdUnitFuncAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd").new(instance, func_name, args)
+
+
+## An assertion tool to verify for emitted signals until a certain time.
+func assert_signal(instance: Object) -> GdUnitSignalAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd").new(instance)
+
+
+## An assertion tool to test for failing assertions.[br]
+## This assert is only designed for internal use to verify failing asserts working as expected.[br]
+## Usage:
+## [codeblock]
+## assert_failure(func(): assert_bool(true).is_not_equal(true)) \
+## .has_message("Expecting:\n 'true'\n not equal to\n 'true'")
+## [/codeblock]
+func assert_failure(assertion: Callable) -> GdUnitFailureAssert:
+ @warning_ignore("unsafe_method_access")
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd").new().execute(assertion)
+
+
+## An assertion tool to test for failing assertions.[br]
+## This assert is only designed for internal use to verify failing asserts working as expected.[br]
+## Usage:
+## [codeblock]
+## await assert_failure_await(func(): assert_bool(true).is_not_equal(true)) \
+## .has_message("Expecting:\n 'true'\n not equal to\n 'true'")
+## [/codeblock]
+func assert_failure_await(assertion: Callable) -> GdUnitFailureAssert:
+ @warning_ignore("unsafe_method_access")
+ return await __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd").new().execute_and_await(assertion)
+
+
+## An assertion tool to verify Godot errors.[br]
+## You can use to verify certain Godot errors like failing assertions, push_error, push_warn.[br]
+## Usage:
+## [codeblock]
+## # tests no error occurred during execution of the code
+## await assert_error(func (): return 0 )\
+## .is_success()
+##
+## # tests a push_error('test error') occured during execution of the code
+## await assert_error(func (): push_error('test error') )\
+## .is_push_error('test error')
+## [/codeblock]
+func assert_error(current: Callable) -> GdUnitGodotErrorAssert:
+ return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd").new(current)
+
+
+## Explicitly fails the current test indicating that the feature is not yet implemented.[br]
+## This function is useful during development when you want to write test cases before implementing the actual functionality.[br]
+## It provides a clear indication that the test failure is expected because the feature is still under development.[br]
+## Usage:
+## [codeblock]
+## # Test for a feature that will be implemented later
+## func test_advanced_ai_behavior():
+## assert_not_yet_implemented()
+##
+## [/codeblock]
+func assert_not_yet_implemented() -> void:
+ @warning_ignore("unsafe_method_access")
+ __gdunit_assert().new(null).do_fail()
+
+
+## Explicitly fails the current test with a custom error message.[br]
+## This function reports an error but does not terminate test execution automatically.[br]
+## You must use 'return' after calling fail() to stop the test since GDScript has no exception support.[br]
+## Useful for complex conditional testing scenarios where standard assertions are insufficient.[br]
+## Usage:
+## [codeblock]
+## # Fail test when conditions are not met
+## if !custom_check(player):
+## fail("Player should be alive but has %d health" % player.health)
+## return
+##
+## # Continue with test if conditions pass
+## assert_that(player.health).is_greater(0)
+## [/codeblock]
+func fail(message: String) -> void:
+ @warning_ignore("unsafe_method_access")
+ __gdunit_assert().new(null).report_error(message)
+
+
+# --- internal stuff do not override!!!
+func ResourcePath() -> String:
+ return get_script().resource_path
diff --git a/addons/gdUnit4/src/GdUnitTestSuite.gd.uid b/addons/gdUnit4/src/GdUnitTestSuite.gd.uid
new file mode 100644
index 0000000..848c896
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitTestSuite.gd.uid
@@ -0,0 +1 @@
+uid://dssjps8fb6m2w
diff --git a/addons/gdUnit4/src/GdUnitTuple.gd b/addons/gdUnit4/src/GdUnitTuple.gd
new file mode 100644
index 0000000..9d56dd8
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitTuple.gd
@@ -0,0 +1,86 @@
+## A tuple implementation for GdUnit4 test assertions and value extraction.
+## @tutorial(GdUnit4 Array Assertions): https://mikeschulze.github.io/gdUnit4/latest/testing/assert-array/#extractv
+## @tutorial(GdUnit4 Testing Framework): https://mikeschulze.github.io/gdUnit4/
+## [br]
+## The GdUnitTuple class is a utility container designed specifically for the GdUnit4
+## testing framework. It enables advanced assertion operations, particularly when
+## extracting and comparing multiple values from complex test results.
+## [br]
+## [b]Primary Use Cases in Testing:[/b] [br]
+## - Extracting multiple properties from test objects with [method extractv]## [br]
+## - Grouping related assertion values for comparison## [br]
+## - Returning multiple values from test helper methods## [br]
+## - Organizing expected vs actual value pairs in assertions## [br]
+## [br]
+## [b]Example Usage in Tests:[/b]
+## [codeblock]
+## func test_player_stats_after_level_up():
+## var player = Player.new()
+## player.level_up()
+##
+## # Extract multiple properties using tuple
+## assert_array([player]) \
+## .extractv(extr("name"), extr("level"), extr("hp")) \
+## .contains(tuple("Hero", 2, 150))
+##
+## func test_enemy_spawn_positions():
+## var enemies: Array = spawn_enemies(3)
+##
+## # Verify multiple enemies have correct position data
+## assert_array(enemies) \
+## .extractv(extr("position.x"), extr("position.y")) \
+## .contains_exactly([
+## tuple(100, 200),
+## tuple(150, 200),
+## tuple(200, 200)
+## ])
+## [/codeblock]
+## [br]
+## [b]Integration with GdUnit4 Assertions:[/b] [br]
+## Tuples work seamlessly with array assertion methods like: [br]
+## - [code]contains()[/code] - Check if extracted values contain specific tuples [br]
+## - [code]contains_exactly()[/code] - Verify exact tuple matches [br]
+## - [code]is_equal()[/code] - Compare tuple equality [br]
+## [br]
+## [b]Note:[/b] This class is part of the GdUnit4 testing framework's internal
+## utilities and is primarily intended for use within test assertions rather
+## than production code.
+class_name GdUnitTuple
+extends RefCounted
+
+var _values: Array = []
+
+
+## Initializes a new GdUnitTuple with test values.
+## [br]
+## Creates a tuple to hold multiple values extracted from test objects
+## or expected values for assertions. Commonly used with the [code]tuple()[/code]
+## helper function in GdUnit4 tests.
+## [br]
+## [b]Parameters:[/b]
+## - [code]...args[/code]: Variable number of values to store.
+func _init(...args: Array) -> void:
+ _values = args
+
+
+## Returns the tuple's values as an array for assertion comparisons.
+## [br]
+## Provides access to the stored test values. Used internally by GdUnit4's
+## assertion system when comparing tuples in test validations.
+## [br]
+## [b]Returns:[/b]
+## An [Array] containing all values stored in the tuple.
+func values() -> Array:
+ return _values
+
+
+## Returns a string representation for test output and debugging.
+## [br]
+## Formats the tuple for display in test results, error messages, and debug logs.
+## This method is automatically called by GdUnit4 when displaying assertion
+## failures involving tuples.
+## [br]
+## [b]Returns:[/b]
+## A [String] in the format "tuple([value1, value2, ...])"
+func _to_string() -> String:
+ return "tuple(%s)" % str(_values)
diff --git a/addons/gdUnit4/src/GdUnitTuple.gd.uid b/addons/gdUnit4/src/GdUnitTuple.gd.uid
new file mode 100644
index 0000000..a5307d6
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitTuple.gd.uid
@@ -0,0 +1 @@
+uid://b1rha2l11wgl6
diff --git a/addons/gdUnit4/src/GdUnitValueExtractor.gd b/addons/gdUnit4/src/GdUnitValueExtractor.gd
new file mode 100644
index 0000000..1a34445
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitValueExtractor.gd
@@ -0,0 +1,9 @@
+## This is the base interface for value extraction
+class_name GdUnitValueExtractor
+extends RefCounted
+
+
+## Extracts a value by given implementation
+func extract_value(value :Variant) -> Variant:
+ push_error("Uninplemented func 'extract_value'")
+ return value
diff --git a/addons/gdUnit4/src/GdUnitValueExtractor.gd.uid b/addons/gdUnit4/src/GdUnitValueExtractor.gd.uid
new file mode 100644
index 0000000..0e54c3f
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitValueExtractor.gd.uid
@@ -0,0 +1 @@
+uid://de3g3qotv2c5v
diff --git a/addons/gdUnit4/src/GdUnitVectorAssert.gd b/addons/gdUnit4/src/GdUnitVectorAssert.gd
new file mode 100644
index 0000000..c186cba
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitVectorAssert.gd
@@ -0,0 +1,55 @@
+## An Assertion Tool to verify Vector values
+@abstract class_name GdUnitVectorAssert
+extends GdUnitAssert
+
+
+## Verifies that the current value is null.
+@abstract func is_null() -> GdUnitVectorAssert
+
+
+## Verifies that the current value is not null.
+@abstract func is_not_null() -> GdUnitVectorAssert
+
+
+## Verifies that the current value is equal to the given one.
+@abstract func is_equal(expected: Variant) -> GdUnitVectorAssert
+
+
+## Verifies that the current value is not equal to expected one.
+@abstract func is_not_equal(expected: Variant) -> GdUnitVectorAssert
+
+
+## Verifies that the current and expected value are approximately equal.
+@abstract func is_equal_approx(expected: Variant, approx: Variant) -> GdUnitVectorAssert
+
+
+## Overrides the default failure message by given custom message.
+@abstract func override_failure_message(message: String) -> GdUnitVectorAssert
+
+
+## Appends a custom message to the failure message.
+@abstract func append_failure_message(message: String) -> GdUnitVectorAssert
+
+
+## Verifies that the current value is less than the given one.
+@abstract func is_less(expected: Variant) -> GdUnitVectorAssert
+
+
+## Verifies that the current value is less than or equal the given one.
+@abstract func is_less_equal(expected: Variant) -> GdUnitVectorAssert
+
+
+## Verifies that the current value is greater than the given one.
+@abstract func is_greater(expected: Variant) -> GdUnitVectorAssert
+
+
+## Verifies that the current value is greater than or equal the given one.
+@abstract func is_greater_equal(expected: Variant) -> GdUnitVectorAssert
+
+
+## Verifies that the current value is between the given boundaries (inclusive).
+@abstract func is_between(from: Variant, to: Variant) -> GdUnitVectorAssert
+
+
+## Verifies that the current value is not between the given boundaries (inclusive).
+@abstract func is_not_between(from: Variant, to: Variant) -> GdUnitVectorAssert
diff --git a/addons/gdUnit4/src/GdUnitVectorAssert.gd.uid b/addons/gdUnit4/src/GdUnitVectorAssert.gd.uid
new file mode 100644
index 0000000..aaf7d12
--- /dev/null
+++ b/addons/gdUnit4/src/GdUnitVectorAssert.gd.uid
@@ -0,0 +1 @@
+uid://2p2gh0psu842
diff --git a/addons/gdUnit4/src/asserts/CallBackValueProvider.gd b/addons/gdUnit4/src/asserts/CallBackValueProvider.gd
new file mode 100644
index 0000000..6be4b3e
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/CallBackValueProvider.gd
@@ -0,0 +1,25 @@
+# a value provider unsing a callback to get `next` value from a certain function
+class_name CallBackValueProvider
+extends ValueProvider
+
+var _cb :Callable
+var _args :Array
+
+
+func _init(instance :Object, func_name :String, args :Array = Array(), force_error := true) -> void:
+ _cb = Callable(instance, func_name);
+ _args = args
+ if force_error and not _cb.is_valid():
+ push_error("Can't find function '%s' checked instance %s" % [func_name, instance])
+
+
+func get_value() -> Variant:
+ if not _cb.is_valid():
+ return null
+ if _args.is_empty():
+ return await _cb.call()
+ return await _cb.callv(_args)
+
+
+func dispose() -> void:
+ _cb = Callable()
diff --git a/addons/gdUnit4/src/asserts/CallBackValueProvider.gd.uid b/addons/gdUnit4/src/asserts/CallBackValueProvider.gd.uid
new file mode 100644
index 0000000..20374e1
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/CallBackValueProvider.gd.uid
@@ -0,0 +1 @@
+uid://da0u7lr425ojb
diff --git a/addons/gdUnit4/src/asserts/DefaultValueProvider.gd b/addons/gdUnit4/src/asserts/DefaultValueProvider.gd
new file mode 100644
index 0000000..2f828fa
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/DefaultValueProvider.gd
@@ -0,0 +1,13 @@
+# default value provider, simple returns the initial value
+class_name DefaultValueProvider
+extends ValueProvider
+
+var _value: Variant
+
+
+func _init(value: Variant) -> void:
+ _value = value
+
+
+func get_value() -> Variant:
+ return _value
diff --git a/addons/gdUnit4/src/asserts/DefaultValueProvider.gd.uid b/addons/gdUnit4/src/asserts/DefaultValueProvider.gd.uid
new file mode 100644
index 0000000..0759472
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/DefaultValueProvider.gd.uid
@@ -0,0 +1 @@
+uid://d0oppd5oc256x
diff --git a/addons/gdUnit4/src/asserts/GdAssertMessages.gd b/addons/gdUnit4/src/asserts/GdAssertMessages.gd
new file mode 100644
index 0000000..5a2fa1c
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdAssertMessages.gd
@@ -0,0 +1,752 @@
+class_name GdAssertMessages
+extends Resource
+
+const WARN_COLOR = "#EFF883"
+const ERROR_COLOR = "#CD5C5C"
+const VALUE_COLOR = "#1E90FF"
+const SUB_COLOR := Color(1, 0, 0, .15)
+const ADD_COLOR := Color(0, 1, 0, .15)
+
+
+# Dictionary of control characters and their readable representations
+const CONTROL_CHARS = {
+ "\n": "<LF>", # Line Feed
+ "\r": "<CR>", # Carriage Return
+ "\t": "<TAB>", # Tab
+ "\b": "<BS>", # Backspace
+ "\f": "<FF>", # Form Feed
+ "\v": "<VT>", # Vertical Tab
+ "\a": "<BEL>", # Bell
+ "": "<ESC>" # Escape
+}
+
+
+static func format_dict(value :Variant) -> String:
+ if not value is Dictionary:
+ return str(value)
+
+ var dict_value: Dictionary = value
+ if dict_value.is_empty():
+ return "{ }"
+ var as_rows := var_to_str(value).split("\n")
+ for index in range( 1, as_rows.size()-1):
+ as_rows[index] = " " + as_rows[index]
+ as_rows[-1] = " " + as_rows[-1]
+ return "\n".join(as_rows)
+
+
+# improved version of InputEvent as text
+static func input_event_as_text(event :InputEvent) -> String:
+ var text := ""
+ if event is InputEventKey:
+ var key_event := event as InputEventKey
+ text += """
+ InputEventKey : keycode=%s (%s) pressed: %s
+ physical_keycode: %s
+ location: %s
+ echo: %s""" % [
+ key_event.keycode,
+ key_event.as_text_keycode(),
+ key_event.pressed,
+ key_event.physical_keycode,
+ key_event.location,
+ key_event.echo]
+ else:
+ text += event.as_text()
+ if event is InputEventMouse:
+ var mouse_event := event as InputEventMouse
+ text += """
+ global_position: %s""" % mouse_event.global_position
+ if event is InputEventWithModifiers:
+ var mouse_event := event as InputEventWithModifiers
+ text += """
+ --------
+ mods: %s
+ shift: %s
+ alt: %s
+ control: %s
+ meta: %s
+ command: %s""" % [
+ mouse_event.get_modifiers_mask(),
+ mouse_event.shift_pressed,
+ mouse_event.alt_pressed,
+ mouse_event.ctrl_pressed,
+ mouse_event.meta_pressed,
+ mouse_event.command_or_control_autoremap]
+ return text.dedent()
+
+
+static func _colored_string_div(characters: String) -> String:
+ return colored_array_div(characters.to_utf32_buffer().to_int32_array())
+
+
+static func colored_array_div(characters: PackedInt32Array) -> String:
+ if characters.is_empty():
+ return "<empty>"
+ var result := PackedInt32Array()
+ var index := 0
+ var missing_chars := PackedInt32Array()
+ var additional_chars := PackedInt32Array()
+
+ while index < characters.size():
+ var character := characters[index]
+ match character:
+ GdDiffTool.DIV_ADD:
+ index += 1
+ @warning_ignore("return_value_discarded")
+ additional_chars.append(characters[index])
+ GdDiffTool.DIV_SUB:
+ index += 1
+ @warning_ignore("return_value_discarded")
+ missing_chars.append(characters[index])
+ _:
+ if not missing_chars.is_empty():
+ result.append_array(format_chars(missing_chars, SUB_COLOR))
+ missing_chars = PackedInt32Array()
+ if not additional_chars.is_empty():
+ result.append_array(format_chars(additional_chars, ADD_COLOR))
+ additional_chars = PackedInt32Array()
+ @warning_ignore("return_value_discarded")
+ result.append(character)
+ index += 1
+
+ result.append_array(format_chars(missing_chars, SUB_COLOR))
+ result.append_array(format_chars(additional_chars, ADD_COLOR))
+ return result.to_byte_array().get_string_from_utf32()
+
+
+static func _typed_value(value :Variant) -> String:
+ return GdDefaultValueDecoder.decode(value)
+
+
+static func _warning(error :String) -> String:
+ return "[color=%s]%s[/color]" % [WARN_COLOR, error]
+
+
+static func _error(error :String) -> String:
+ return "[color=%s]%s[/color]" % [ERROR_COLOR, error]
+
+
+static func _nerror(number :Variant) -> String:
+ match typeof(number):
+ TYPE_INT:
+ return "[color=%s]%d[/color]" % [ERROR_COLOR, number]
+ TYPE_FLOAT:
+ return "[color=%s]%f[/color]" % [ERROR_COLOR, number]
+ _:
+ return "[color=%s]%s[/color]" % [ERROR_COLOR, str(number)]
+
+
+static func _colored(value: Variant, color: Color) -> String:
+ return "[color=%s]%s[/color]" % [color.to_html(), value]
+
+
+static func _colored_value(value :Variant) -> String:
+ match typeof(value):
+ TYPE_STRING, TYPE_STRING_NAME:
+ return "'[color=%s]%s[/color]'" % [VALUE_COLOR, _colored_string_div(str(value))]
+ TYPE_INT:
+ return "'[color=%s]%d[/color]'" % [VALUE_COLOR, value]
+ TYPE_FLOAT:
+ return "'[color=%s]%s[/color]'" % [VALUE_COLOR, _typed_value(value)]
+ TYPE_COLOR:
+ return "'[color=%s]%s[/color]'" % [VALUE_COLOR, _typed_value(value)]
+ TYPE_OBJECT:
+ if value == null:
+ return "'[color=%s]<null>[/color]'" % [VALUE_COLOR]
+ if value is InputEvent:
+ var ie: InputEvent = value
+ return "[color=%s]<%s>[/color]" % [VALUE_COLOR, input_event_as_text(ie)]
+ var obj_value: Object = value
+ if obj_value.has_method("_to_string"):
+ return "[color=%s]<%s>[/color]" % [VALUE_COLOR, str(value)]
+ return "[color=%s]<%s>[/color]" % [VALUE_COLOR, obj_value.get_class()]
+ TYPE_DICTIONARY:
+ return "'[color=%s]%s[/color]'" % [VALUE_COLOR, format_dict(value)]
+ _:
+ if GdArrayTools.is_array_type(value):
+ return "'[color=%s]%s[/color]'" % [VALUE_COLOR, _typed_value(value)]
+ return "'[color=%s]%s[/color]'" % [VALUE_COLOR, value]
+
+
+
+static func _index_report_as_table(index_reports :Array) -> String:
+ var table := "[table=3]$cells[/table]"
+ var header := "[cell][right][b]$text[/b][/right]\t[/cell]"
+ var cell := "[cell][right]$text[/right]\t[/cell]"
+ var cells := header.replace("$text", "Index") + header.replace("$text", "Current") + header.replace("$text", "Expected")
+ for report :Variant in index_reports:
+ var index :String = str(report["index"])
+ var current :String = str(report["current"])
+ var expected :String = str(report["expected"])
+ cells += cell.replace("$text", index) + cell.replace("$text", current) + cell.replace("$text", expected)
+ return table.replace("$cells", cells)
+
+
+
+static func orphan_warning(orphans_count: int) -> String:
+ return """
+ %s: Found %s possible orphan nodes.
+ Add %s to the end of the test to collect details.""".dedent().trim_prefix("\n") % [
+ _warning("WARNING:"),
+ _nerror(orphans_count),
+ _colored_value("collect_orphan_node_details()")
+ ]
+
+static func orphan_detected_on_suite_setup(orphans: Array[GdUnitOrphanNodeInfo]) -> String:
+ return """
+ %s Detected %s orphan nodes!
+ [b]Verify your test suite setup.[/b]
+ %s""".dedent().trim_prefix("\n") % [
+ _warning("WARNING:"),
+ _nerror(orphans.size()),
+ _build_orphan_node_stacktrace(orphans)]
+
+
+static func orphan_detected_on_test_setup(orphans: Array[GdUnitOrphanNodeInfo]) -> String:
+ return """
+ %s Detected %s orphan nodes on test setup!
+ [b]Check before_test() and after_test()![/b]
+ %s""".dedent().trim_prefix("\n") % [
+ _warning("WARNING:"),
+ _nerror(orphans.size()),
+ _build_orphan_node_stacktrace(orphans)
+ ]
+
+
+static func orphan_detected_on_test(orphans: Array[GdUnitOrphanNodeInfo]) -> String:
+ return """
+ %s Detected %s orphan nodes!
+ %s""".dedent().trim_prefix("\n") % [
+ _warning("WARNING:"),
+ _nerror(orphans.size()),
+ _build_orphan_node_stacktrace(orphans)
+ ]
+
+
+static func _build_orphan_node_stacktrace(orphans: Array[GdUnitOrphanNodeInfo]) -> String:
+ var stack_trace := "\n"
+ for orphan in orphans:
+ stack_trace += orphan.as_trace(orphan, true) + "\n"
+ return stack_trace.indent(" ")
+
+
+
+static func fuzzer_interuped(iterations: int, error: String) -> String:
+ return "%s %s %s\n %s" % [
+ _error("Found an error after"),
+ _colored_value(iterations + 1),
+ _error("test iterations"),
+ error]
+
+
+static func test_timeout(timeout :int) -> String:
+ return "%s\n %s" % [_error("Timeout !"), _colored_value("Test timed out after %s" % LocalTime.elapsed(timeout))]
+
+
+static func test_session_terminated() -> String:
+ return "%s" % _error("Test Session Terminated")
+
+
+# gdlint:disable = mixed-tabs-and-spaces
+static func test_suite_skipped(hint :String, skip_count :int) -> String:
+ return """
+ %s
+ Skipped %s tests
+ Reason: %s
+ """.dedent().trim_prefix("\n")\
+ % [_error("The Entire test-suite is skipped!"), _colored_value(skip_count), _colored_value(hint)]
+
+
+static func test_skipped(hint :String) -> String:
+ return """
+ %s
+ Reason: %s
+ """.dedent().trim_prefix("\n")\
+ % [_error("This test is skipped!"), _colored_value(hint)]
+
+
+static func error_not_implemented() -> String:
+ return _error("Test not implemented!")
+
+
+static func error_is_null(current :Variant) -> String:
+ return "%s %s but was %s" % [_error("Expecting:"), _colored_value(null), _colored_value(current)]
+
+
+static func error_is_not_null() -> String:
+ return "%s %s" % [_error("Expecting: not to be"), _colored_value(null)]
+
+
+static func error_equal(current :Variant, expected :Variant, index_reports :Array = []) -> String:
+ var report := """
+ %s
+ %s
+ but was
+ %s""".dedent().trim_prefix("\n") % [_error("Expecting:"), _colored_value(expected), _colored_value(current)]
+ if not index_reports.is_empty():
+ report += "\n\n%s\n%s" % [_error("Differences found:"), _index_report_as_table(index_reports)]
+ return report
+
+
+static func error_not_equal(current :Variant, expected :Variant) -> String:
+ return "%s\n %s\n not equal to\n %s" % [_error("Expecting:"), _colored_value(expected), _colored_value(current)]
+
+
+static func error_not_equal_case_insensetiv(current :Variant, expected :Variant) -> String:
+ return "%s\n %s\n not equal to (case insensitiv)\n %s" % [
+ _error("Expecting:"), _colored_value(expected), _colored_value(current)]
+
+
+static func error_is_empty(current :Variant) -> String:
+ return "%s\n must be empty but was\n %s" % [_error("Expecting:"), _colored_value(current)]
+
+
+static func error_is_not_empty() -> String:
+ return "%s\n must not be empty" % [_error("Expecting:")]
+
+
+static func error_is_same(current :Variant, expected :Variant) -> String:
+ return "%s\n %s\n to refer to the same object\n %s" % [_error("Expecting:"), _colored_value(expected), _colored_value(current)]
+
+
+@warning_ignore("unused_parameter")
+static func error_not_same(_current :Variant, expected :Variant) -> String:
+ return "%s\n %s" % [_error("Expecting not same:"), _colored_value(expected)]
+
+
+static func error_not_same_error(current :Variant, expected :Variant) -> String:
+ return "%s\n %s\n but was\n %s" % [_error("Expecting error message:"), _colored_value(expected), _colored_value(current)]
+
+
+static func error_is_instanceof(current: GdUnitResult, expected :GdUnitResult) -> String:
+ return "%s\n %s\n But it was %s" % [_error("Expected instance of:"),\
+ _colored_value(expected.or_else(null)), _colored_value(current.or_else(null))]
+
+
+# -- Boolean Assert specific messages -----------------------------------------------------
+static func error_is_true(current :Variant) -> String:
+ return "%s %s but is %s" % [_error("Expecting:"), _colored_value(true), _colored_value(current)]
+
+
+static func error_is_false(current :Variant) -> String:
+ return "%s %s but is %s" % [_error("Expecting:"), _colored_value(false), _colored_value(current)]
+
+
+# - Integer/Float Assert specific messages -----------------------------------------------------
+
+static func error_is_even(current :Variant) -> String:
+ return "%s\n %s must be even" % [_error("Expecting:"), _colored_value(current)]
+
+
+static func error_is_odd(current :Variant) -> String:
+ return "%s\n %s must be odd" % [_error("Expecting:"), _colored_value(current)]
+
+
+static func error_is_negative(current :Variant) -> String:
+ return "%s\n %s be negative" % [_error("Expecting:"), _colored_value(current)]
+
+
+static func error_is_not_negative(current :Variant) -> String:
+ return "%s\n %s be not negative" % [_error("Expecting:"), _colored_value(current)]
+
+
+static func error_is_zero(current :Variant) -> String:
+ return "%s\n equal to 0 but is %s" % [_error("Expecting:"), _colored_value(current)]
+
+
+static func error_is_not_zero() -> String:
+ return "%s\n not equal to 0" % [_error("Expecting:")]
+
+
+static func error_is_wrong_type(current_type :Variant.Type, expected_type :Variant.Type) -> String:
+ return "%s\n Expecting type %s but is %s" % [
+ _error("Unexpected type comparison:"),
+ _colored_value(GdObjects.type_as_string(current_type)),
+ _colored_value(GdObjects.type_as_string(expected_type))]
+
+
+static func error_is_value(operation :int, current :Variant, expected :Variant, expected2 :Variant = null) -> String:
+ match operation:
+ Comparator.EQUAL:
+ return "%s\n %s but was '%s'" % [_error("Expecting:"), _colored_value(expected), _nerror(current)]
+ Comparator.LESS_THAN:
+ return "%s\n %s but was '%s'" % [_error("Expecting to be less than:"), _colored_value(expected), _nerror(current)]
+ Comparator.LESS_EQUAL:
+ return "%s\n %s but was '%s'" % [_error("Expecting to be less than or equal:"), _colored_value(expected), _nerror(current)]
+ Comparator.GREATER_THAN:
+ return "%s\n %s but was '%s'" % [_error("Expecting to be greater than:"), _colored_value(expected), _nerror(current)]
+ Comparator.GREATER_EQUAL:
+ return "%s\n %s but was '%s'" % [_error("Expecting to be greater than or equal:"), _colored_value(expected), _nerror(current)]
+ Comparator.BETWEEN_EQUAL:
+ return "%s\n %s\n in range between\n %s <> %s" % [
+ _error("Expecting:"), _colored_value(current), _colored_value(expected), _colored_value(expected2)]
+ Comparator.NOT_BETWEEN_EQUAL:
+ return "%s\n %s\n not in range between\n %s <> %s" % [
+ _error("Expecting:"), _colored_value(current), _colored_value(expected), _colored_value(expected2)]
+ return "TODO create expected message"
+
+
+static func error_is_in(current :Variant, expected :Array) -> String:
+ return "%s\n %s\n is in\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(str(expected))]
+
+
+static func error_is_not_in(current :Variant, expected :Array) -> String:
+ return "%s\n %s\n is not in\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(str(expected))]
+
+
+# - StringAssert ---------------------------------------------------------------------------------
+static func error_equal_ignoring_case(current :Variant, expected :Variant) -> String:
+ return "%s\n %s\n but was\n %s (ignoring case)" % [_error("Expecting:"), _colored_value(expected), _colored_value(current)]
+
+
+static func error_contains(current :Variant, expected :Variant) -> String:
+ return "%s\n %s\n do contains\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)]
+
+
+static func error_not_contains(current :Variant, expected :Variant) -> String:
+ return "%s\n %s\n not do contain\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)]
+
+
+static func error_contains_ignoring_case(current :Variant, expected :Variant) -> String:
+ return "%s\n %s\n contains\n %s\n (ignoring case)" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)]
+
+
+static func error_not_contains_ignoring_case(current :Variant, expected :Variant) -> String:
+ return "%s\n %s\n not do contains\n %s\n (ignoring case)" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)]
+
+
+static func error_starts_with(current :Variant, expected :Variant) -> String:
+ return "%s\n %s\n to start with\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)]
+
+
+static func error_ends_with(current :Variant, expected :Variant) -> String:
+ return "%s\n %s\n to end with\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)]
+
+
+static func error_has_length(current :Variant, expected: int, compare_operator :int) -> String:
+ @warning_ignore("unsafe_method_access")
+ var current_length :Variant = current.length() if current != null else null
+ match compare_operator:
+ Comparator.EQUAL:
+ return "%s\n %s but was '%s' in\n %s" % [
+ _error("Expecting size:"), _colored_value(expected), _nerror(current_length), _colored_value(current)]
+ Comparator.LESS_THAN:
+ return "%s\n %s but was '%s' in\n %s" % [
+ _error("Expecting size to be less than:"), _colored_value(expected), _nerror(current_length), _colored_value(current)]
+ Comparator.LESS_EQUAL:
+ return "%s\n %s but was '%s' in\n %s" % [
+ _error("Expecting size to be less than or equal:"), _colored_value(expected),
+ _nerror(current_length), _colored_value(current)]
+ Comparator.GREATER_THAN:
+ return "%s\n %s but was '%s' in\n %s" % [
+ _error("Expecting size to be greater than:"), _colored_value(expected),
+ _nerror(current_length), _colored_value(current)]
+ Comparator.GREATER_EQUAL:
+ return "%s\n %s but was '%s' in\n %s" % [
+ _error("Expecting size to be greater than or equal:"), _colored_value(expected),
+ _nerror(current_length), _colored_value(current)]
+ return "TODO create expected message"
+
+
+# - ArrayAssert specific messgaes ---------------------------------------------------
+
+static func error_arr_contains(current: Variant, expected: Variant, not_expect: Variant, not_found: Variant, by_reference: bool) -> String:
+ var failure_message := "Expecting contains SAME elements:" if by_reference else "Expecting contains elements:"
+ var error := "%s\n %s\n do contains (in any order)\n %s" % [
+ _error(failure_message), _colored_value(current), _colored_value(expected)]
+ if not is_empty(not_expect):
+ error += "\nbut some elements where not expected:\n %s" % _colored_value(not_expect)
+ if not is_empty(not_found):
+ var prefix := "but" if is_empty(not_expect) else "and"
+ error += "\n%s could not find elements:\n %s" % [prefix, _colored_value(not_found)]
+ return error
+
+
+static func error_arr_contains_exactly(
+ current: Variant,
+ expected: Variant,
+ not_expect: Variant,
+ not_found: Variant, compare_mode: GdObjects.COMPARE_MODE) -> String:
+ var failure_message := (
+ "Expecting contains exactly elements:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST
+ else "Expecting contains SAME exactly elements:"
+ )
+ if is_empty(not_expect) and is_empty(not_found):
+ var arr_current: Array = current
+ var arr_expected: Array = expected
+ var diff := _find_first_diff(arr_current, arr_expected)
+ return "%s\n %s\n do contains (in same order)\n %s\n but has different order %s" % [
+ _error(failure_message), _colored_value(current), _colored_value(expected), diff]
+
+ var error := "%s\n %s\n do contains (in same order)\n %s" % [
+ _error(failure_message), _colored_value(current), _colored_value(expected)]
+ if not is_empty(not_expect):
+ error += "\nbut some elements where not expected:\n %s" % _colored_value(not_expect)
+ if not is_empty(not_found):
+ var prefix := "but" if is_empty(not_expect) else "and"
+ error += "\n%s could not find elements:\n %s" % [prefix, _colored_value(not_found)]
+ return error
+
+
+static func error_arr_contains_exactly_in_any_order(
+ current: Variant,
+ expected: Variant,
+ not_expect: Variant,
+ not_found: Variant,
+ compare_mode: GdObjects.COMPARE_MODE) -> String:
+
+ var failure_message := (
+ "Expecting contains exactly elements:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST
+ else "Expecting contains SAME exactly elements:"
+ )
+ var error := "%s\n %s\n do contains exactly (in any order)\n %s" % [
+ _error(failure_message), _colored_value(current), _colored_value(expected)]
+ if not is_empty(not_expect):
+ error += "\nbut some elements where not expected:\n %s" % _colored_value(not_expect)
+ if not is_empty(not_found):
+ var prefix := "but" if is_empty(not_expect) else "and"
+ error += "\n%s could not find elements:\n %s" % [prefix, _colored_value(not_found)]
+ return error
+
+
+static func error_arr_not_contains(current: Variant, expected: Variant, found: Variant, compare_mode: GdObjects.COMPARE_MODE) -> String:
+ var failure_message := "Expecting:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST else "Expecting SAME:"
+ var error := "%s\n %s\n do not contains\n %s" % [
+ _error(failure_message), _colored_value(current), _colored_value(expected)]
+ if not is_empty(found):
+ error += "\n but found elements:\n %s" % _colored_value(found)
+ return error
+
+
+# - DictionaryAssert specific messages ----------------------------------------------
+static func error_contains_keys(current :Array, expected :Array, keys_not_found :Array, compare_mode :GdObjects.COMPARE_MODE) -> String:
+ var failure := (
+ "Expecting contains keys:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST
+ else "Expecting contains SAME keys:"
+ )
+ return "%s\n %s\n to contains:\n %s\n but can't find key's:\n %s" % [
+ _error(failure), _colored_value(current), _colored_value(expected), _colored_value(keys_not_found)]
+
+
+static func error_not_contains_keys(current :Array, expected :Array, keys_not_found :Array, compare_mode :GdObjects.COMPARE_MODE) -> String:
+ var failure := (
+ "Expecting NOT contains keys:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST
+ else "Expecting NOT contains SAME keys"
+ )
+ return "%s\n %s\n do not contains:\n %s\n but contains key's:\n %s" % [
+ _error(failure), _colored_value(current), _colored_value(expected), _colored_value(keys_not_found)]
+
+
+static func error_contains_key_value(key :Variant, value :Variant, current_value :Variant, compare_mode :GdObjects.COMPARE_MODE) -> String:
+ var failure := (
+ "Expecting contains key and value:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST
+ else "Expecting contains SAME key and value:"
+ )
+ return "%s\n %s : %s\n but contains\n %s : %s" % [
+ _error(failure), _colored_value(key), _colored_value(value), _colored_value(key), _colored_value(current_value)]
+
+
+# - ResultAssert specific errors ----------------------------------------------------
+static func error_result_is_empty(current :GdUnitResult) -> String:
+ return _result_error_message(current, GdUnitResult.EMPTY)
+
+
+static func error_result_is_success(current :GdUnitResult) -> String:
+ return _result_error_message(current, GdUnitResult.SUCCESS)
+
+
+static func error_result_is_warning(current :GdUnitResult) -> String:
+ return _result_error_message(current, GdUnitResult.WARN)
+
+
+static func error_result_is_error(current :GdUnitResult) -> String:
+ return _result_error_message(current, GdUnitResult.ERROR)
+
+
+static func error_result_has_message(current :String, expected :String) -> String:
+ return "%s\n %s\n but was\n %s." % [_error("Expecting:"), _colored_value(expected), _colored_value(current)]
+
+
+static func error_result_has_message_on_success(expected :String) -> String:
+ return "%s\n %s\n but the GdUnitResult is a success." % [_error("Expecting:"), _colored_value(expected)]
+
+
+static func error_result_is_value(current :Variant, expected :Variant) -> String:
+ return "%s\n %s\n but was\n %s." % [_error("Expecting to contain same value:"), _colored_value(expected), _colored_value(current)]
+
+
+static func _result_error_message(current :GdUnitResult, expected_type :int) -> String:
+ if current == null:
+ return _error("Expecting the result must be a %s but was <null>." % result_type(expected_type))
+ if current.is_success():
+ return _error("Expecting the result must be a %s but was SUCCESS." % result_type(expected_type))
+ var error := "Expecting the result must be a %s but was %s:" % [result_type(expected_type), result_type(current._state)]
+ return "%s\n %s" % [_error(error), _colored_value(result_message(current))]
+
+
+static func error_interrupted(func_name :String, expected :Variant, elapsed :String) -> String:
+ func_name = humanized(func_name)
+ if expected == null:
+ return "%s %s but timed out after %s" % [_error("Expected:"), func_name, elapsed]
+ return "%s %s %s but timed out after %s" % [_error("Expected:"), func_name, _colored_value(expected), elapsed]
+
+
+static func error_wait_signal(signal_name :String, args :Array, elapsed :String) -> String:
+ if args.is_empty():
+ return "%s %s but timed out after %s" % [
+ _error("Expecting emit signal:"), _colored_value(signal_name + "()"), elapsed]
+ return "%s %s but timed out after %s" % [
+ _error("Expecting emit signal:"), _colored_value(signal_name + "(" + str(args) + ")"), elapsed]
+
+
+static func error_signal_emitted(signal_name :String, args :Array, elapsed :String) -> String:
+ if args.is_empty():
+ return "%s %s but is emitted after %s" % [
+ _error("Expecting do not emit signal:"), _colored_value(signal_name + "()"), elapsed]
+ return "%s %s but is emitted after %s" % [
+ _error("Expecting do not emit signal:"), _colored_value(signal_name + "(" + str(args) + ")"), elapsed]
+
+
+static func error_await_signal_on_invalid_instance(source :Variant, signal_name :String, args :Array) -> String:
+ return "%s\n await_signal_on(%s, %s, %s)" % [
+ _error("Invalid source! Can't await on signal:"), _colored_value(source), signal_name, args]
+
+
+static func result_type(type :int) -> String:
+ match type:
+ GdUnitResult.SUCCESS: return "SUCCESS"
+ GdUnitResult.WARN: return "WARNING"
+ GdUnitResult.ERROR: return "ERROR"
+ GdUnitResult.EMPTY: return "EMPTY"
+ return "UNKNOWN"
+
+
+static func result_message(result :GdUnitResult) -> String:
+ match result._state:
+ GdUnitResult.SUCCESS: return ""
+ GdUnitResult.WARN: return result.warn_message()
+ GdUnitResult.ERROR: return result.error_message()
+ GdUnitResult.EMPTY: return ""
+ return "UNKNOWN"
+# -----------------------------------------------------------------------------------
+
+# - Spy|Mock specific errors ----------------------------------------------------
+static func error_no_more_interactions(summary :Dictionary) -> String:
+ var interactions := PackedStringArray()
+ for args :Array in summary.keys():
+ var times :int = summary[args]
+ @warning_ignore("return_value_discarded")
+ interactions.append(_format_arguments(args, times))
+ return "%s\n%s\n%s" % [_error("Expecting no more interactions!"), _error("But found interactions on:"), "\n".join(interactions)]
+
+
+static func error_validate_interactions(current_interactions: Dictionary, expected_interactions: Dictionary) -> String:
+ var collected_interactions := PackedStringArray()
+ for args: Array in current_interactions.keys():
+ var times: int = current_interactions[args]
+ @warning_ignore("return_value_discarded")
+ collected_interactions.append(_format_arguments(args, times))
+
+ var arguments: Array = expected_interactions.keys()[0]
+ var interactions: int = expected_interactions.values()[0]
+ var expected_interaction := _format_arguments(arguments, interactions)
+ return "%s\n%s\n%s\n%s" % [
+ _error("Expecting interaction on:"), expected_interaction, _error("But found interactions on:"), "\n".join(collected_interactions)]
+
+
+static func _format_arguments(args :Array, times :int) -> String:
+ var fname :String = args[0]
+ var fargs := args.slice(1) as Array
+ var typed_args := _to_typed_args(fargs)
+ var fsignature := _colored_value("%s(%s)" % [fname, ", ".join(typed_args)])
+ return " %s %d time's" % [fsignature, times]
+
+
+static func _to_typed_args(args :Array) -> PackedStringArray:
+ var typed := PackedStringArray()
+ for arg :Variant in args:
+ @warning_ignore("return_value_discarded")
+ typed.append(_format_arg(arg) + " :" + GdObjects.type_as_string(typeof(arg)))
+ return typed
+
+
+static func _format_arg(arg :Variant) -> String:
+ if arg is InputEvent:
+ var ie: InputEvent = arg
+ return input_event_as_text(ie)
+ return str(arg)
+
+
+static func _find_first_diff(left :Array, right :Array) -> String:
+ for index in left.size():
+ var l :Variant = left[index]
+ var r :Variant = "<no entry>" if index >= right.size() else right[index]
+ if not GdObjects.equals(l, r):
+ return "at position %s\n '%s' vs '%s'" % [_colored_value(index), _typed_value(l), _typed_value(r)]
+ return ""
+
+
+static func error_has_size(current :Variant, expected: int) -> String:
+ @warning_ignore("unsafe_method_access")
+ var current_size :Variant = null if current == null else current.size()
+ return "%s\n %s\n but was\n %s" % [_error("Expecting size:"), _colored_value(expected), _colored_value(current_size)]
+
+
+static func error_contains_exactly(current: Array, expected: Array) -> String:
+ return "%s\n %s\n but was\n %s" % [_error("Expecting exactly equal:"), _colored_value(expected), _colored_value(current)]
+
+
+static func format_chars(characters: PackedInt32Array, type: Color) -> PackedInt32Array:
+ if characters.size() == 0:# or characters[0] == 10:
+ return characters
+
+ # Replace each control character with its readable form
+ var formatted_text := characters.to_byte_array().get_string_from_utf32()
+ for control_char: String in CONTROL_CHARS:
+ var replace_text: String = CONTROL_CHARS[control_char]
+ formatted_text = formatted_text.replace(control_char, replace_text)
+
+ # Handle special ASCII control characters (0x00-0x1F, 0x7F)
+ var ascii_text := ""
+ for i in formatted_text.length():
+ var character := formatted_text[i]
+ var code := character.unicode_at(0)
+ if code < 0x20 and not CONTROL_CHARS.has(character): # Control characters not handled above
+ ascii_text += "<0x%02X>" % code
+ elif code == 0x7F: # DEL character
+ ascii_text += "<DEL>"
+ else:
+ ascii_text += character
+
+ var message := "[bgcolor=#%s][color=white]%s[/color][/bgcolor]" % [
+ type.to_html(),
+ ascii_text
+ ]
+
+ var result := PackedInt32Array()
+ result.append_array(message.to_utf32_buffer().to_int32_array())
+ return result
+
+
+static func format_invalid(value :String) -> String:
+ return "[bgcolor=#%s][color=with]%s[/color][/bgcolor]" % [SUB_COLOR.to_html(), value]
+
+
+static func humanized(value :String) -> String:
+ return value.replace("_", " ")
+
+
+static func build_failure_message(failure :String, additional_failure_message: String, custom_failure_message: String) -> String:
+ var message := failure if custom_failure_message.is_empty() else custom_failure_message
+ if additional_failure_message.is_empty():
+ return message
+ return """
+ %s
+ [color=LIME_GREEN][b]Additional info:[/b][/color]
+ %s""".dedent().trim_prefix("\n") % [message, additional_failure_message]
+
+
+static func is_empty(value: Variant) -> bool:
+ var arry_value: Array = value
+ return arry_value != null and arry_value.is_empty()
diff --git a/addons/gdUnit4/src/asserts/GdAssertMessages.gd.uid b/addons/gdUnit4/src/asserts/GdAssertMessages.gd.uid
new file mode 100644
index 0000000..f1336de
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdAssertMessages.gd.uid
@@ -0,0 +1 @@
+uid://cf4cfpmy5gb5s
diff --git a/addons/gdUnit4/src/asserts/GdAssertReports.gd b/addons/gdUnit4/src/asserts/GdAssertReports.gd
new file mode 100644
index 0000000..06b72ed
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdAssertReports.gd
@@ -0,0 +1,54 @@
+class_name GdAssertReports
+extends RefCounted
+
+const LAST_ERROR = "last_assert_error_message"
+const LAST_ERROR_LINE = "last_assert_error_line"
+
+
+static func report_success() -> void:
+ GdUnitSignals.instance().gdunit_set_test_failed.emit(false)
+ GdAssertReports.set_last_error_line_number(-1)
+ Engine.remove_meta(LAST_ERROR)
+
+
+static func report_warning(message :String, line_number :int) -> void:
+ GdUnitSignals.instance().gdunit_set_test_failed.emit(false)
+ send_report(GdUnitReport.new().create(GdUnitReport.WARN, line_number, message))
+
+
+static func report_error(message:String, line_number :int) -> void:
+ GdUnitSignals.instance().gdunit_set_test_failed.emit(true)
+ GdAssertReports.set_last_error_line_number(line_number)
+ Engine.set_meta(LAST_ERROR, message)
+ # if we expect to fail we handle as success test
+ if _do_expect_assert_failing():
+ return
+ send_report(GdUnitReport.new().create(GdUnitReport.FAILURE, line_number, message))
+
+
+static func reset_last_error_line_number() -> void:
+ Engine.remove_meta(LAST_ERROR_LINE)
+
+
+static func set_last_error_line_number(line_number :int) -> void:
+ Engine.set_meta(LAST_ERROR_LINE, line_number)
+
+
+static func get_last_error_line_number() -> int:
+ if Engine.has_meta(LAST_ERROR_LINE):
+ return Engine.get_meta(LAST_ERROR_LINE)
+ return -1
+
+
+static func _do_expect_assert_failing() -> bool:
+ if Engine.has_meta(GdUnitConstants.EXPECT_ASSERT_REPORT_FAILURES):
+ return Engine.get_meta(GdUnitConstants.EXPECT_ASSERT_REPORT_FAILURES)
+ return false
+
+
+static func current_failure() -> String:
+ return Engine.get_meta(LAST_ERROR)
+
+
+static func send_report(report :GdUnitReport) -> void:
+ GdUnitThreadManager.get_current_context().get_execution_context().add_report(report)
diff --git a/addons/gdUnit4/src/asserts/GdAssertReports.gd.uid b/addons/gdUnit4/src/asserts/GdAssertReports.gd.uid
new file mode 100644
index 0000000..10cae25
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdAssertReports.gd.uid
@@ -0,0 +1 @@
+uid://cnmp765i0d7kk
diff --git a/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd
new file mode 100644
index 0000000..63b45a0
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd
@@ -0,0 +1,424 @@
+class_name GdUnitArrayAssertImpl
+extends GdUnitArrayAssert
+
+
+var _base: GdUnitAssertImpl
+var _current_value_provider: ValueProvider
+var _type_check: bool
+
+
+func _init(current: Variant, type_check := true) -> void:
+ _type_check = type_check
+ _current_value_provider = DefaultValueProvider.new(current)
+ _base = GdUnitAssertImpl.new(current)
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ if not _validate_value_type(current):
+ @warning_ignore("return_value_discarded")
+ report_error("GdUnitArrayAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current))
+
+
+func _notification(event: int) -> void:
+ if event == NOTIFICATION_PREDELETE:
+ if _base != null:
+ _base.notification(event)
+ _base = null
+
+
+func report_success() -> GdUnitArrayAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_success()
+ return self
+
+
+func report_error(error: String) -> GdUnitArrayAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_error(error)
+ return self
+
+
+func failure_message() -> String:
+ return _base.failure_message()
+
+
+func override_failure_message(message: String) -> GdUnitArrayAssert:
+ @warning_ignore("return_value_discarded")
+ _base.override_failure_message(message)
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitArrayAssert:
+ @warning_ignore("return_value_discarded")
+ _base.append_failure_message(message)
+ return self
+
+
+func _validate_value_type(value: Variant) -> bool:
+ return value == null or GdArrayTools.is_array_type(value)
+
+
+func get_current_value() -> Variant:
+ return _current_value_provider.get_value()
+
+
+func max_length(left: Variant, right: Variant) -> int:
+ var ls := str(left).length()
+ var rs := str(right).length()
+ return rs if ls < rs else ls
+
+
+# gdlint: disable=function-name
+func _toPackedStringArray(value: Variant) -> PackedStringArray:
+ if GdArrayTools.is_array_type(value):
+ @warning_ignore("unsafe_cast")
+ return PackedStringArray(value as Array)
+ return PackedStringArray([str(value)])
+
+
+func _array_equals_div(current: Variant, expected: Variant, case_sensitive: bool = false) -> Array:
+ var current_value := _toPackedStringArray(current)
+ var expected_value := _toPackedStringArray(expected)
+ var index_report := Array()
+ for index in current_value.size():
+ var c := current_value[index]
+ if index < expected_value.size():
+ var e := expected_value[index]
+ if not GdObjects.equals(c, e, case_sensitive):
+ var length := max_length(c, e)
+ current_value[index] = GdAssertMessages.format_invalid(c.lpad(length))
+ expected_value[index] = e.lpad(length)
+ index_report.push_back({"index": index, "current": c, "expected": e})
+ else:
+ current_value[index] = GdAssertMessages.format_invalid(c)
+ index_report.push_back({"index": index, "current": c, "expected": "<N/A>"})
+
+ for index in range(current_value.size(), expected_value.size()):
+ var value := expected_value[index]
+ expected_value[index] = GdAssertMessages.format_invalid(value)
+ index_report.push_back({"index": index, "current": "<N/A>", "expected": value})
+ return [current_value, expected_value, index_report]
+
+
+func _array_div(compare_mode: GdObjects.COMPARE_MODE, left: Array[Variant], right: Array[Variant], _same_order := false) -> Array[Variant]:
+ var not_expect := left.duplicate(true)
+ var not_found := right.duplicate(true)
+ for index_c in left.size():
+ var c: Variant = left[index_c]
+ for index_e in right.size():
+ var e: Variant = right[index_e]
+ if GdObjects.equals(c, e, false, compare_mode):
+ GdArrayTools.erase_value(not_expect, e)
+ GdArrayTools.erase_value(not_found, c)
+ break
+ return [not_expect, not_found]
+
+
+func _contains(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert:
+ var by_reference := compare_mode == GdObjects.COMPARE_MODE.OBJECT_REFERENCE
+ var current_value: Variant = get_current_value()
+ var expected_value: Variant = _extract_variadic_value(expected)
+ if not _validate_value_type(expected_value):
+ return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
+
+ if current_value == null:
+ return report_error(GdAssertMessages.error_arr_contains(current_value, expected_value, [], expected_value, by_reference))
+ @warning_ignore("unsafe_cast")
+ var diffs := _array_div(compare_mode, current_value as Array[Variant], expected_value as Array[Variant])
+ #var not_expect := diffs[0] as Array
+ var not_found: Array = diffs[1]
+ if not not_found.is_empty():
+ return report_error(GdAssertMessages.error_arr_contains(current_value, expected_value, [], not_found, by_reference))
+ return report_success()
+
+
+func _contains_exactly(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert:
+ var current_value: Variant = get_current_value()
+ var expected_value: Variant = _extract_variadic_value(expected)
+ if not _validate_value_type(expected_value):
+ return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
+
+ if current_value == null:
+ return report_error(GdAssertMessages.error_arr_contains_exactly(null, expected_value, [], expected_value, compare_mode))
+ # has same content in same order
+ if _is_equal(current_value, expected_value, false, compare_mode):
+ return report_success()
+ # check has same elements but in different order
+ if _is_equals_sorted(current_value, expected_value, false, compare_mode):
+ return report_error(GdAssertMessages.error_arr_contains_exactly(current_value, expected_value, [], [], compare_mode))
+ # find the difference
+ @warning_ignore("unsafe_cast")
+ var diffs := _array_div(compare_mode,
+ current_value as Array[Variant],
+ expected_value as Array[Variant],
+ GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
+ var not_expect: Array[Variant] = diffs[0]
+ var not_found: Array[Variant] = diffs[1]
+ return report_error(GdAssertMessages.error_arr_contains_exactly(current_value, expected_value, not_expect, not_found, compare_mode))
+
+
+func _contains_exactly_in_any_order(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert:
+ var current_value: Variant = get_current_value()
+ var expected_value: Variant = _extract_variadic_value(expected)
+ if not _validate_value_type(expected_value):
+ return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
+
+ if current_value == null:
+ return report_error(GdAssertMessages.error_arr_contains_exactly_in_any_order(current_value, expected_value, [],
+ expected_value, compare_mode))
+ # find the difference
+ @warning_ignore("unsafe_cast")
+ var diffs := _array_div(compare_mode, current_value as Array[Variant], expected_value as Array[Variant], false)
+ var not_expect: Array[Variant] = diffs[0]
+ var not_found: Array[Variant] = diffs[1]
+ if not_expect.is_empty() and not_found.is_empty():
+ return report_success()
+ return report_error(GdAssertMessages.error_arr_contains_exactly_in_any_order(current_value, expected_value, not_expect,
+ not_found, compare_mode))
+
+
+func _not_contains(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert:
+ var current_value: Variant = get_current_value()
+ var expected_value: Variant = _extract_variadic_value(expected)
+ if not _validate_value_type(expected_value):
+ return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
+ if current_value == null:
+ return report_error(GdAssertMessages.error_arr_contains_exactly_in_any_order(current_value, expected_value, [],
+ expected_value, compare_mode))
+ @warning_ignore("unsafe_cast")
+ var diffs := _array_div(compare_mode, current_value as Array[Variant], expected_value as Array[Variant])
+ var found: Array[Variant] = diffs[0]
+ @warning_ignore("unsafe_cast")
+ if found.size() == (current_value as Array).size():
+ return report_success()
+ @warning_ignore("unsafe_cast")
+ var diffs2 := _array_div(compare_mode, expected_value as Array[Variant], diffs[1] as Array[Variant])
+ return report_error(GdAssertMessages.error_arr_not_contains(current_value, expected_value, diffs2[0], compare_mode))
+
+
+func is_null() -> GdUnitArrayAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_null()
+ return self
+
+
+func is_not_null() -> GdUnitArrayAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_null()
+ return self
+
+
+func is_equal(...expected: Array) -> GdUnitArrayAssert:
+ var current_value: Variant = get_current_value()
+ var expected_value: Variant= _extract_variadic_value(expected)
+ if not _validate_value_type(expected_value):
+ return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
+ if current_value == null and expected_value != null:
+ return report_error(GdAssertMessages.error_equal(null, expected_value))
+
+ if not _is_equal(current_value, expected_value):
+ var diff := _array_equals_div(current_value, expected_value)
+ var expected_as_list := GdArrayTools.as_string(diff[0], false)
+ var current_as_list := GdArrayTools.as_string(diff[1], false)
+ var index_report: Array = diff[2]
+ return report_error(GdAssertMessages.error_equal(expected_as_list, current_as_list, index_report))
+ return report_success()
+
+
+# Verifies that the current Array is equal to the given one, ignoring case considerations.
+func is_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert:
+ var current_value: Variant = get_current_value()
+ var expected_value: Variant = _extract_variadic_value(expected)
+ if not _validate_value_type(expected_value):
+ return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
+ if current_value == null and expected_value != null:
+ @warning_ignore("unsafe_cast")
+ return report_error(GdAssertMessages.error_equal(null, GdArrayTools.as_string(expected_value)))
+
+ if not _is_equal(current_value, expected_value, true):
+ @warning_ignore("unsafe_cast")
+ var diff := _array_equals_div(current_value, expected_value, true)
+ var expected_as_list := GdArrayTools.as_string(diff[0])
+ var current_as_list := GdArrayTools.as_string(diff[1])
+ var index_report: Array = diff[2]
+ return report_error(GdAssertMessages.error_equal(expected_as_list, current_as_list, index_report))
+ return report_success()
+
+
+func is_not_equal(...expected: Array) -> GdUnitArrayAssert:
+ var current_value: Variant = get_current_value()
+ var expected_value: Variant = _extract_variadic_value(expected)
+ if not _validate_value_type(expected_value):
+ return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
+
+ if _is_equal(current_value, expected_value):
+ return report_error(GdAssertMessages.error_not_equal(current_value, expected_value))
+ return report_success()
+
+
+func is_not_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert:
+ var current_value: Variant = get_current_value()
+ var expected_value: Variant = _extract_variadic_value(expected)
+ if not _validate_value_type(expected_value):
+ return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
+
+ if _is_equal(current_value, expected_value, true):
+ @warning_ignore("unsafe_cast")
+ var c := GdArrayTools.as_string(current_value as Array)
+ @warning_ignore("unsafe_cast")
+ var e := GdArrayTools.as_string(expected_value)
+ return report_error(GdAssertMessages.error_not_equal_case_insensetiv(c, e))
+ return report_success()
+
+
+func is_empty() -> GdUnitArrayAssert:
+ var current_value: Variant = get_current_value()
+ @warning_ignore("unsafe_cast")
+ if current_value == null or (current_value as Array).size() > 0:
+ return report_error(GdAssertMessages.error_is_empty(current_value))
+ return report_success()
+
+
+func is_not_empty() -> GdUnitArrayAssert:
+ var current_value: Variant = get_current_value()
+ @warning_ignore("unsafe_cast")
+ if current_value != null and (current_value as Array).size() == 0:
+ return report_error(GdAssertMessages.error_is_not_empty())
+ return report_success()
+
+
+@warning_ignore("unused_parameter", "shadowed_global_identifier")
+func is_same(expected: Variant) -> GdUnitArrayAssert:
+ if not _validate_value_type(expected):
+ return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected))
+ var current: Variant = get_current_value()
+ if not is_same(current, expected):
+ @warning_ignore("return_value_discarded")
+ report_error(GdAssertMessages.error_is_same(current, expected))
+ return self
+
+
+func is_not_same(expected: Variant) -> GdUnitArrayAssert:
+ if not _validate_value_type(expected):
+ return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected))
+ var current: Variant = get_current_value()
+ if is_same(current, expected):
+ @warning_ignore("return_value_discarded")
+ report_error(GdAssertMessages.error_not_same(current, expected))
+ return self
+
+
+func has_size(expected: int) -> GdUnitArrayAssert:
+ var current_value: Variant = get_current_value()
+ @warning_ignore("unsafe_cast")
+ if current_value == null or (current_value as Array).size() != expected:
+ return report_error(GdAssertMessages.error_has_size(current_value, expected))
+ return report_success()
+
+
+func contains(...expected: Array) -> GdUnitArrayAssert:
+ return _contains(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
+
+
+func contains_exactly(...expected: Array) -> GdUnitArrayAssert:
+ return _contains_exactly(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
+
+
+func contains_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert:
+ return _contains_exactly_in_any_order(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
+
+
+func contains_same(...expected: Array) -> GdUnitArrayAssert:
+ return _contains(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE)
+
+
+func contains_same_exactly(...expected: Array) -> GdUnitArrayAssert:
+ return _contains_exactly(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE)
+
+
+func contains_same_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert:
+ return _contains_exactly_in_any_order(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE)
+
+
+func not_contains(...expected: Array) -> GdUnitArrayAssert:
+ return _not_contains(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
+
+
+func not_contains_same(...expected: Array) -> GdUnitArrayAssert:
+ return _not_contains(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE)
+
+
+func is_instanceof(expected: Variant) -> GdUnitAssert:
+ @warning_ignore("unsafe_method_access")
+ _base.is_instanceof(expected)
+ return self
+
+
+func extract(func_name: String, ...func_args: Array) -> GdUnitArrayAssert:
+ var extracted_elements := Array()
+ var args: Array = _extract_variadic_value(func_args)
+ var extractor := GdUnitFuncValueExtractor.new(func_name, args)
+ var current: Variant = get_current_value()
+ if current == null:
+ _current_value_provider = DefaultValueProvider.new(null)
+ else:
+ for element: Variant in current:
+ extracted_elements.append(extractor.extract_value(element))
+ _current_value_provider = DefaultValueProvider.new(extracted_elements)
+ return self
+
+
+func extractv(...extractors: Array) -> GdUnitArrayAssert:
+ var extracted_elements := Array()
+ var current: Variant = get_current_value()
+ if current == null:
+ _current_value_provider = DefaultValueProvider.new(null)
+ else:
+ for element: Variant in current:
+ var ev: Array[Variant] = []
+ for index: int in extractors.size():
+ var extractor: GdUnitValueExtractor = extractors[index]
+ ev.append(extractor.extract_value(element))
+ if extractors.size() > 1:
+ extracted_elements.append(GdUnitTuple.new.callv(ev))
+ else:
+ extracted_elements.append(ev[0])
+ _current_value_provider = DefaultValueProvider.new(extracted_elements)
+ return self
+
+
+## Small helper to support the old expected arguments as single array and variadic arguments
+func _extract_variadic_value(values: Variant) -> Variant:
+ @warning_ignore("unsafe_method_access")
+ if values != null and values.size() == 1 and GdArrayTools.is_array_type(values[0]):
+ return values[0]
+ return values
+
+
+@warning_ignore("incompatible_ternary")
+func _is_equal(
+ left: Variant,
+ right: Variant,
+ case_sensitive := false,
+ compare_mode := GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool:
+
+ @warning_ignore("unsafe_cast")
+ return GdObjects.equals(
+ (left as Array) if GdArrayTools.is_array_type(left) else left,
+ (right as Array) if GdArrayTools.is_array_type(right) else right,
+ case_sensitive,
+ compare_mode
+ )
+
+
+func _is_equals_sorted(
+ left: Variant,
+ right: Variant,
+ case_sensitive := false,
+ compare_mode := GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool:
+
+ @warning_ignore("unsafe_cast")
+ return GdObjects.equals_sorted(
+ left as Array,
+ right as Array,
+ case_sensitive,
+ compare_mode)
diff --git a/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd.uid
new file mode 100644
index 0000000..83db7a3
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://cugrq3kd6cfsd
diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd
new file mode 100644
index 0000000..9f578c4
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd
@@ -0,0 +1,80 @@
+class_name GdUnitAssertImpl
+extends GdUnitAssert
+
+
+var _current :Variant
+var _current_failure_message :String = ""
+var _custom_failure_message :String = ""
+var _additional_failure_message: String = ""
+
+
+func _init(current :Variant) -> void:
+ _current = current
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ GdAssertReports.reset_last_error_line_number()
+
+
+
+func failure_message() -> String:
+ return _current_failure_message
+
+
+func current_value() -> Variant:
+ return _current
+
+
+func report_success() -> GdUnitAssert:
+ GdAssertReports.report_success()
+ return self
+
+
+func report_error(failure :String, failure_line_number: int = -1) -> GdUnitAssert:
+ var line_number := failure_line_number if failure_line_number != -1 else GdUnitAssertions.get_line_number()
+ GdAssertReports.set_last_error_line_number(line_number)
+ _current_failure_message = GdAssertMessages.build_failure_message(failure, _additional_failure_message, _custom_failure_message)
+ GdAssertReports.report_error(_current_failure_message, line_number)
+ Engine.set_meta("GD_TEST_FAILURE", true)
+ return self
+
+
+func do_fail() -> GdUnitAssert:
+ return report_error(GdAssertMessages.error_not_implemented())
+
+
+func override_failure_message(message: String) -> GdUnitAssert:
+ _custom_failure_message = message
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitAssert:
+ _additional_failure_message = message
+ return self
+
+
+func is_null() -> GdUnitAssert:
+ var current :Variant = current_value()
+ if current != null:
+ return report_error(GdAssertMessages.error_is_null(current))
+ return report_success()
+
+
+func is_not_null() -> GdUnitAssert:
+ var current :Variant = current_value()
+ if current == null:
+ return report_error(GdAssertMessages.error_is_not_null())
+ return report_success()
+
+
+func is_equal(expected: Variant) -> GdUnitAssert:
+ var current: Variant = current_value()
+ if not GdObjects.equals(current, expected):
+ return report_error(GdAssertMessages.error_equal(current, expected))
+ return report_success()
+
+
+func is_not_equal(expected: Variant) -> GdUnitAssert:
+ var current: Variant = current_value()
+ if GdObjects.equals(current, expected):
+ return report_error(GdAssertMessages.error_not_equal(current, expected))
+ return report_success()
diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd.uid
new file mode 100644
index 0000000..2ca96f7
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://dwhamqtf0w80b
diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertions.gd b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd
new file mode 100644
index 0000000..a5b53c1
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd
@@ -0,0 +1,68 @@
+# Preloads all GdUnit assertions
+class_name GdUnitAssertions
+extends RefCounted
+
+
+@warning_ignore("return_value_discarded")
+func _init() -> void:
+ # preload all gdunit assertions to speedup testsuite loading time
+ # gdlint:disable=private-method-call
+ @warning_ignore_start("return_value_discarded")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd")
+ GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd")
+ @warning_ignore_restore("return_value_discarded")
+
+
+### We now load all used asserts and tool scripts into the cache according to the principle of "lazy loading"
+### in order to noticeably reduce the loading time of the test suite.
+# We go this hard way to increase the loading performance to avoid reparsing all the used scripts
+# for more detailed info -> https://github.com/godotengine/godot/issues/67400
+# gdlint:disable=function-name
+static func __lazy_load(script_path :String) -> GDScript:
+ return ResourceLoader.load(script_path, "GDScript", ResourceLoader.CACHE_MODE_REUSE)
+
+
+static func validate_value_type(value :Variant, type :Variant.Type) -> bool:
+ return value == null or typeof(value) == type
+
+
+# Scans the current stack trace for the root cause to extract the line number
+static func get_line_number() -> int:
+ var stack_trace := get_stack()
+ if stack_trace == null or stack_trace.is_empty():
+ return -1
+ for index in stack_trace.size():
+ var stack_info :Dictionary = stack_trace[index]
+ var function :String = stack_info.get("function")
+ # we catch helper asserts to skip over to return the correct line number
+ if function.begins_with("assert_"):
+ continue
+ if function.begins_with("test_"):
+ return stack_info.get("line")
+ var source :String = stack_info.get("source")
+ if source.is_empty() \
+ or source.begins_with("user://") \
+ or source.ends_with("GdUnitAssert.gd") \
+ or source.ends_with("GdUnitAssertions.gd") \
+ or source.ends_with("AssertImpl.gd") \
+ or source.ends_with("GdUnitTestSuite.gd") \
+ or source.ends_with("GdUnitSceneRunnerImpl.gd") \
+ or source.ends_with("GdUnitObjectInteractions.gd") \
+ or source.ends_with("GdUnitObjectInteractionsVerifier.gd") \
+ or source.ends_with("GdUnitAwaiter.gd"):
+ continue
+ return stack_info.get("line")
+ return -1
diff --git a/addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid
new file mode 100644
index 0000000..87e7ca5
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitAssertions.gd.uid
@@ -0,0 +1 @@
+uid://cp756mi1gtrjl
diff --git a/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd
new file mode 100644
index 0000000..2fc0ce4
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd
@@ -0,0 +1,87 @@
+extends GdUnitBoolAssert
+
+var _base: GdUnitAssertImpl
+
+
+func _init(current :Variant) -> void:
+ _base = GdUnitAssertImpl.new(current)
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ if not GdUnitAssertions.validate_value_type(current, TYPE_BOOL):
+ @warning_ignore("return_value_discarded")
+ report_error("GdUnitBoolAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current))
+
+
+func _notification(event :int) -> void:
+ if event == NOTIFICATION_PREDELETE:
+ if _base != null:
+ _base.notification(event)
+ _base = null
+
+
+func current_value() -> Variant:
+ return _base.current_value()
+
+
+func report_success() -> GdUnitBoolAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_success()
+ return self
+
+
+func report_error(error :String) -> GdUnitBoolAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_error(error)
+ return self
+
+
+func failure_message() -> String:
+ return _base.failure_message()
+
+
+func override_failure_message(message: String) -> GdUnitBoolAssert:
+ @warning_ignore("return_value_discarded")
+ _base.override_failure_message(message)
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitBoolAssert:
+ @warning_ignore("return_value_discarded")
+ _base.append_failure_message(message)
+ return self
+
+
+func is_null() -> GdUnitBoolAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_null()
+ return self
+
+
+func is_not_null() -> GdUnitBoolAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_null()
+ return self
+
+
+func is_equal(expected: Variant) -> GdUnitBoolAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_equal(expected)
+ return self
+
+
+func is_not_equal(expected: Variant) -> GdUnitBoolAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_equal(expected)
+ return self
+
+
+func is_true() -> GdUnitBoolAssert:
+ if current_value() != true:
+ return report_error(GdAssertMessages.error_is_true(current_value()))
+ return report_success()
+
+
+func is_false() -> GdUnitBoolAssert:
+ if current_value() == true || current_value() == null:
+ return report_error(GdAssertMessages.error_is_false(current_value()))
+ return report_success()
diff --git a/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd.uid
new file mode 100644
index 0000000..7cecf08
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://dxdyrrpo58p5x
diff --git a/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd
new file mode 100644
index 0000000..c57bbc2
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd
@@ -0,0 +1,207 @@
+extends GdUnitDictionaryAssert
+
+var _base: GdUnitAssertImpl
+
+
+func _init(current :Variant) -> void:
+ _base = GdUnitAssertImpl.new(current)
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ if not GdUnitAssertions.validate_value_type(current, TYPE_DICTIONARY):
+ @warning_ignore("return_value_discarded")
+ report_error("GdUnitDictionaryAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current))
+
+
+func _notification(event :int) -> void:
+ if event == NOTIFICATION_PREDELETE:
+ if _base != null:
+ _base.notification(event)
+ _base = null
+
+
+func report_success() -> GdUnitDictionaryAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_success()
+ return self
+
+
+func report_error(error :String) -> GdUnitDictionaryAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_error(error)
+ return self
+
+
+func failure_message() -> String:
+ return _base.failure_message()
+
+
+func override_failure_message(message: String) -> GdUnitDictionaryAssert:
+ @warning_ignore("return_value_discarded")
+ _base.override_failure_message(message)
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitDictionaryAssert:
+ @warning_ignore("return_value_discarded")
+ _base.append_failure_message(message)
+ return self
+
+
+func current_value() -> Variant:
+ return _base.current_value()
+
+
+func is_null() -> GdUnitDictionaryAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_null()
+ return self
+
+
+func is_not_null() -> GdUnitDictionaryAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_null()
+ return self
+
+
+func is_equal(expected: Variant) -> GdUnitDictionaryAssert:
+ var current :Variant = current_value()
+ if current == null:
+ return report_error(GdAssertMessages.error_equal(null, GdAssertMessages.format_dict(expected)))
+ if not GdObjects.equals(current, expected):
+ var c := GdAssertMessages.format_dict(current)
+ var e := GdAssertMessages.format_dict(expected)
+ return report_error(GdAssertMessages.error_equal(c, e))
+ return report_success()
+
+
+func is_not_equal(expected: Variant) -> GdUnitDictionaryAssert:
+ var current: Variant = current_value()
+ if GdObjects.equals(current, expected):
+ return report_error(GdAssertMessages.error_not_equal(current, expected))
+ return report_success()
+
+
+@warning_ignore("unused_parameter", "shadowed_global_identifier")
+func is_same(expected :Variant) -> GdUnitDictionaryAssert:
+ var current :Variant = current_value()
+ if current == null:
+ return report_error(GdAssertMessages.error_equal(null, GdAssertMessages.format_dict(expected)))
+ if not is_same(current, expected):
+ var c := GdAssertMessages.format_dict(current)
+ var e := GdAssertMessages.format_dict(expected)
+ return report_error(GdAssertMessages.error_is_same(c, e))
+ return report_success()
+
+
+@warning_ignore("unused_parameter", "shadowed_global_identifier")
+func is_not_same(expected :Variant) -> GdUnitDictionaryAssert:
+ var current :Variant = current_value()
+ if is_same(current, expected):
+ return report_error(GdAssertMessages.error_not_same(current, expected))
+ return report_success()
+
+
+func is_empty() -> GdUnitDictionaryAssert:
+ var current :Variant = current_value()
+ @warning_ignore("unsafe_cast")
+ if current == null or not (current as Dictionary).is_empty():
+ return report_error(GdAssertMessages.error_is_empty(current))
+ return report_success()
+
+
+func is_not_empty() -> GdUnitDictionaryAssert:
+ var current :Variant = current_value()
+ @warning_ignore("unsafe_cast")
+ if current == null or (current as Dictionary).is_empty():
+ return report_error(GdAssertMessages.error_is_not_empty())
+ return report_success()
+
+
+func has_size(expected: int) -> GdUnitDictionaryAssert:
+ var current :Variant = current_value()
+ if current == null:
+ return report_error(GdAssertMessages.error_is_not_null())
+ @warning_ignore("unsafe_cast")
+ if (current as Dictionary).size() != expected:
+ return report_error(GdAssertMessages.error_has_size(current, expected))
+ return report_success()
+
+
+func _contains_keys(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitDictionaryAssert:
+ var current :Variant = current_value()
+ var expected_value: Array = _extract_variadic_value(expected)
+ if current == null:
+ return report_error(GdAssertMessages.error_is_not_null())
+ # find expected keys
+ @warning_ignore("unsafe_cast")
+ var keys_not_found :Array = expected_value.filter(_filter_by_key.bind((current as Dictionary).keys(), compare_mode))
+ if not keys_not_found.is_empty():
+ @warning_ignore("unsafe_cast")
+ return report_error(GdAssertMessages.error_contains_keys((current as Dictionary).keys() as Array, expected_value,
+ keys_not_found, compare_mode))
+ return report_success()
+
+
+func _contains_key_value(key :Variant, value :Variant, compare_mode :GdObjects.COMPARE_MODE) -> GdUnitDictionaryAssert:
+ var current :Variant = current_value()
+ var expected := [key]
+ if current == null:
+ return report_error(GdAssertMessages.error_is_not_null())
+ var dict_current: Dictionary = current
+ var keys_not_found :Array = expected.filter(_filter_by_key.bind(dict_current.keys(), compare_mode))
+ if not keys_not_found.is_empty():
+ return report_error(GdAssertMessages.error_contains_keys(dict_current.keys() as Array, expected, keys_not_found, compare_mode))
+ if not GdObjects.equals(dict_current[key], value, false, compare_mode):
+ return report_error(GdAssertMessages.error_contains_key_value(key, value, dict_current[key], compare_mode))
+ return report_success()
+
+
+func _not_contains_keys(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitDictionaryAssert:
+ var current :Variant = current_value()
+ var expected_value: Array = _extract_variadic_value(expected)
+ if current == null:
+ return report_error(GdAssertMessages.error_is_not_null())
+ var dict_current: Dictionary = current
+ var keys_found :Array = dict_current.keys().filter(_filter_by_key.bind(expected_value, compare_mode, true))
+ if not keys_found.is_empty():
+ return report_error(GdAssertMessages.error_not_contains_keys(dict_current.keys() as Array, expected_value, keys_found, compare_mode))
+ return report_success()
+
+
+func contains_keys(...expected: Array) -> GdUnitDictionaryAssert:
+ return _contains_keys(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
+
+
+func contains_key_value(key :Variant, value :Variant) -> GdUnitDictionaryAssert:
+ return _contains_key_value(key, value, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
+
+
+func not_contains_keys(...expected: Array) -> GdUnitDictionaryAssert:
+ return _not_contains_keys(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
+
+
+func contains_same_keys(expected :Array) -> GdUnitDictionaryAssert:
+ return _contains_keys(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE)
+
+
+func contains_same_key_value(key :Variant, value :Variant) -> GdUnitDictionaryAssert:
+ return _contains_key_value(key, value, GdObjects.COMPARE_MODE.OBJECT_REFERENCE)
+
+
+func not_contains_same_keys(...expected: Array) -> GdUnitDictionaryAssert:
+ return _not_contains_keys(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE)
+
+
+func _filter_by_key(element :Variant, values :Array, compare_mode :GdObjects.COMPARE_MODE, is_not :bool = false) -> bool:
+ for key :Variant in values:
+ if GdObjects.equals(key, element, false, compare_mode):
+ return is_not
+ return !is_not
+
+
+## Small helper to support the old expected arguments as single array and variadic arguments
+func _extract_variadic_value(values: Variant) -> Variant:
+ @warning_ignore("unsafe_method_access")
+ if values != null and values.size() == 1 and GdArrayTools.is_array_type(values[0]):
+ return values[0]
+ return values
diff --git a/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd.uid
new file mode 100644
index 0000000..4ba626d
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://cxrjs0mlg1qr0
diff --git a/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd
new file mode 100644
index 0000000..198624c
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd
@@ -0,0 +1,136 @@
+extends GdUnitFailureAssert
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+var _is_failed := false
+var _failure_message: String
+var _current_failure_message := ""
+var _custom_failure_message := ""
+var _additional_failure_message := ""
+
+
+func _set_do_expect_fail(enabled :bool = true) -> void:
+ Engine.set_meta(GdUnitConstants.EXPECT_ASSERT_REPORT_FAILURES, enabled)
+
+
+func execute_and_await(assertion :Callable, do_await := true) -> GdUnitFailureAssert:
+ # do not report any failure from the original assertion we want to test
+ _set_do_expect_fail(true)
+ var thread_context := GdUnitThreadManager.get_current_context()
+ thread_context.set_assert(null)
+ @warning_ignore("return_value_discarded")
+ GdUnitSignals.instance().gdunit_set_test_failed.connect(_on_test_failed)
+ # execute the given assertion as callable
+ if do_await:
+ await assertion.call()
+ else:
+ assertion.call()
+ _set_do_expect_fail(false)
+ # get the assert instance from current tread context
+ var current_assert := thread_context.get_assert()
+ if not is_instance_of(current_assert, GdUnitAssert):
+ _is_failed = true
+ _failure_message = "Invalid Callable! It must be a callable of 'GdUnitAssert'"
+ return self
+ @warning_ignore("unsafe_method_access")
+ _failure_message = current_assert.failure_message()
+ return self
+
+
+func execute(assertion :Callable) -> GdUnitFailureAssert:
+ @warning_ignore("return_value_discarded")
+ execute_and_await(assertion, false)
+ return self
+
+
+func _on_test_failed(value :bool) -> void:
+ _is_failed = value
+
+
+func is_equal(_expected: Variant) -> GdUnitFailureAssert:
+ return _report_error("Not implemented")
+
+
+func is_not_equal(_expected: Variant) -> GdUnitFailureAssert:
+ return _report_error("Not implemented")
+
+
+func is_null() -> GdUnitFailureAssert:
+ return _report_error("Not implemented")
+
+
+func is_not_null() -> GdUnitFailureAssert:
+ return _report_error("Not implemented")
+
+
+func override_failure_message(message: String) -> GdUnitFailureAssert:
+ _custom_failure_message = message
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitFailureAssert:
+ _additional_failure_message = message
+ return self
+
+
+func is_success() -> GdUnitFailureAssert:
+ if _is_failed:
+ return _report_error("Expect: assertion ends successfully.")
+ return self
+
+
+func is_failed() -> GdUnitFailureAssert:
+ if not _is_failed:
+ return _report_error("Expect: assertion fails.")
+ return self
+
+
+func has_line(expected :int) -> GdUnitFailureAssert:
+ var current := GdAssertReports.get_last_error_line_number()
+ if current != expected:
+ return _report_error("Expect: to failed on line '%d'\n but was '%d'." % [expected, current])
+ return self
+
+
+func has_message(expected :String) -> GdUnitFailureAssert:
+ @warning_ignore("return_value_discarded")
+ is_failed()
+ var expected_error := GdUnitTools.normalize_text(GdUnitTools.richtext_normalize(expected))
+ var current_error := GdUnitTools.normalize_text(GdUnitTools.richtext_normalize(_failure_message))
+ if current_error != expected_error:
+ var diffs := GdDiffTool.string_diff(current_error, expected_error)
+ var current := GdAssertMessages.colored_array_div(diffs[1])
+ return _report_error(GdAssertMessages.error_not_same_error(current, expected_error))
+ return self
+
+
+func contains_message(expected :String) -> GdUnitFailureAssert:
+ var expected_error := GdUnitTools.normalize_text(expected)
+ var current_error := GdUnitTools.normalize_text(GdUnitTools.richtext_normalize(_failure_message))
+ if not current_error.contains(expected_error):
+ var diffs := GdDiffTool.string_diff(current_error, expected_error)
+ var current := GdAssertMessages.colored_array_div(diffs[1])
+ return _report_error(GdAssertMessages.error_not_same_error(current, expected_error))
+ return self
+
+
+func starts_with_message(expected :String) -> GdUnitFailureAssert:
+ var expected_error := GdUnitTools.normalize_text(expected)
+ var current_error := GdUnitTools.normalize_text(GdUnitTools.richtext_normalize(_failure_message))
+ if current_error.find(expected_error) != 0:
+ var diffs := GdDiffTool.string_diff(current_error, expected_error)
+ var current := GdAssertMessages.colored_array_div(diffs[1])
+ return _report_error(GdAssertMessages.error_not_same_error(current, expected_error))
+ return self
+
+
+func _report_error(error_message :String, failure_line_number: int = -1) -> GdUnitAssert:
+ var line_number := failure_line_number if failure_line_number != -1 else GdUnitAssertions.get_line_number()
+ _current_failure_message = GdAssertMessages.build_failure_message(error_message, _additional_failure_message, _custom_failure_message)
+ GdAssertReports.report_error(_current_failure_message, line_number)
+ return self
+
+
+func _report_success() -> GdUnitFailureAssert:
+ GdAssertReports.report_success()
+ return self
diff --git a/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd.uid
new file mode 100644
index 0000000..cf78a00
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://cr53a1nnhmaqa
diff --git a/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd
new file mode 100644
index 0000000..c4f9570
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd
@@ -0,0 +1,116 @@
+extends GdUnitFileAssert
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+var _base: GdUnitAssertImpl
+
+
+func _init(current :Variant) -> void:
+ _base = GdUnitAssertImpl.new(current)
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ if not GdUnitAssertions.validate_value_type(current, TYPE_STRING):
+ @warning_ignore("return_value_discarded")
+ report_error("GdUnitFileAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current))
+
+
+func _notification(event :int) -> void:
+ if event == NOTIFICATION_PREDELETE:
+ if _base != null:
+ _base.notification(event)
+ _base = null
+
+
+func current_value() -> String:
+ return _base.current_value()
+
+
+func report_success() -> GdUnitFileAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_success()
+ return self
+
+
+func report_error(error :String) -> GdUnitFileAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_error(error)
+ return self
+
+
+func failure_message() -> String:
+ return _base.failure_message()
+
+
+func override_failure_message(message: String) -> GdUnitFileAssert:
+ @warning_ignore("return_value_discarded")
+ _base.override_failure_message(message)
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitFileAssert:
+ @warning_ignore("return_value_discarded")
+ _base.append_failure_message(message)
+ return self
+
+
+func is_null() -> GdUnitFileAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_null()
+ return self
+
+
+func is_not_null() -> GdUnitFileAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_null()
+ return self
+
+
+func is_equal(expected: Variant) -> GdUnitFileAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_equal(expected)
+ return self
+
+
+func is_not_equal(expected: Variant) -> GdUnitFileAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_equal(expected)
+ return self
+
+
+func is_file() -> GdUnitFileAssert:
+ var current := current_value()
+ if FileAccess.open(current, FileAccess.READ) == null:
+ return report_error("Is not a file '%s', error code %s" % [current, FileAccess.get_open_error()])
+ return report_success()
+
+
+func exists() -> GdUnitFileAssert:
+ var current := current_value()
+ if not FileAccess.file_exists(current):
+ return report_error("The file '%s' not exists" %current)
+ return report_success()
+
+
+func is_script() -> GdUnitFileAssert:
+ var current := current_value()
+ if FileAccess.open(current, FileAccess.READ) == null:
+ return report_error("Can't acces the file '%s'! Error code %s" % [current, FileAccess.get_open_error()])
+
+ var script := load(current)
+ if not script is GDScript:
+ return report_error("The file '%s' is not a GdScript" % current)
+ return report_success()
+
+
+func contains_exactly(expected_rows: Array) -> GdUnitFileAssert:
+ var current := current_value()
+ if FileAccess.open(current, FileAccess.READ) == null:
+ return report_error("Can't acces the file '%s'! Error code %s" % [current, FileAccess.get_open_error()])
+
+ var script: GDScript = load(current)
+ if script is GDScript:
+ var source_code := GdScriptParser.to_unix_format(script.source_code)
+ var rows := Array(source_code.split("\n"))
+ @warning_ignore("return_value_discarded")
+ GdUnitArrayAssertImpl.new(rows).contains_exactly(expected_rows)
+ return self
diff --git a/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd.uid
new file mode 100644
index 0000000..b8360b7
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://mr5x0e3q4g0a
diff --git a/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd
new file mode 100644
index 0000000..83d7e05
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd
@@ -0,0 +1,159 @@
+extends GdUnitFloatAssert
+
+var _base: GdUnitAssertImpl
+
+
+func _init(current :Variant) -> void:
+ _base = GdUnitAssertImpl.new(current)
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ if not GdUnitAssertions.validate_value_type(current, TYPE_FLOAT):
+ @warning_ignore("return_value_discarded")
+ report_error("GdUnitFloatAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current))
+
+
+func _notification(event :int) -> void:
+ if event == NOTIFICATION_PREDELETE:
+ if _base != null:
+ _base.notification(event)
+ _base = null
+
+
+func current_value() -> Variant:
+ return _base.current_value()
+
+
+func report_success() -> GdUnitFloatAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_success()
+ return self
+
+
+func report_error(error :String) -> GdUnitFloatAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_error(error)
+ return self
+
+
+func failure_message() -> String:
+ return _base.failure_message()
+
+
+func override_failure_message(message: String) -> GdUnitFloatAssert:
+ @warning_ignore("return_value_discarded")
+ _base.override_failure_message(message)
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitFloatAssert:
+ @warning_ignore("return_value_discarded")
+ _base.append_failure_message(message)
+ return self
+
+
+func is_null() -> GdUnitFloatAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_null()
+ return self
+
+
+func is_not_null() -> GdUnitFloatAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_null()
+ return self
+
+
+func is_equal(expected: Variant) -> GdUnitFloatAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_equal(expected)
+ return self
+
+
+func is_not_equal(expected: Variant) -> GdUnitFloatAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_equal(expected)
+ return self
+
+
+@warning_ignore("shadowed_global_identifier")
+func is_equal_approx(expected :float, approx :float) -> GdUnitFloatAssert:
+ return is_between(expected-approx, expected+approx)
+
+
+func is_less(expected :float) -> GdUnitFloatAssert:
+ var current :Variant = current_value()
+ if current == null or current >= expected:
+ return report_error(GdAssertMessages.error_is_value(Comparator.LESS_THAN, current, expected))
+ return report_success()
+
+
+func is_less_equal(expected :float) -> GdUnitFloatAssert:
+ var current :Variant = current_value()
+ if current == null or current > expected:
+ return report_error(GdAssertMessages.error_is_value(Comparator.LESS_EQUAL, current, expected))
+ return report_success()
+
+
+func is_greater(expected :float) -> GdUnitFloatAssert:
+ var current :Variant = current_value()
+ if current == null or current <= expected:
+ return report_error(GdAssertMessages.error_is_value(Comparator.GREATER_THAN, current, expected))
+ return report_success()
+
+
+func is_greater_equal(expected :float) -> GdUnitFloatAssert:
+ var current :Variant = current_value()
+ if current == null or current < expected:
+ return report_error(GdAssertMessages.error_is_value(Comparator.GREATER_EQUAL, current, expected))
+ return report_success()
+
+
+func is_negative() -> GdUnitFloatAssert:
+ var current :Variant = current_value()
+ if current == null or current >= 0.0:
+ return report_error(GdAssertMessages.error_is_negative(current))
+ return report_success()
+
+
+func is_not_negative() -> GdUnitFloatAssert:
+ var current :Variant = current_value()
+ if current == null or current < 0.0:
+ return report_error(GdAssertMessages.error_is_not_negative(current))
+ return report_success()
+
+
+func is_zero() -> GdUnitFloatAssert:
+ var current :Variant = current_value()
+ @warning_ignore("unsafe_cast")
+ if current == null or not is_equal_approx(0.00000000, current as float):
+ return report_error(GdAssertMessages.error_is_zero(current))
+ return report_success()
+
+
+func is_not_zero() -> GdUnitFloatAssert:
+ var current :Variant = current_value()
+ @warning_ignore("unsafe_cast")
+ if current == null or is_equal_approx(0.00000000, current as float):
+ return report_error(GdAssertMessages.error_is_not_zero())
+ return report_success()
+
+
+func is_in(expected :Array) -> GdUnitFloatAssert:
+ var current :Variant = current_value()
+ if not expected.has(current):
+ return report_error(GdAssertMessages.error_is_in(current, expected))
+ return report_success()
+
+
+func is_not_in(expected :Array) -> GdUnitFloatAssert:
+ var current :Variant = current_value()
+ if expected.has(current):
+ return report_error(GdAssertMessages.error_is_not_in(current, expected))
+ return report_success()
+
+
+func is_between(from :float, to :float) -> GdUnitFloatAssert:
+ var current :Variant = current_value()
+ if current == null or current < from or current > to:
+ return report_error(GdAssertMessages.error_is_value(Comparator.BETWEEN_EQUAL, current, from, to))
+ return report_success()
diff --git a/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd.uid
new file mode 100644
index 0000000..3d0dc07
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://cj7rei3qtlurx
diff --git a/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd
new file mode 100644
index 0000000..c38acf0
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd
@@ -0,0 +1,177 @@
+extends GdUnitFuncAssert
+
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+const DEFAULT_TIMEOUT := 2000
+
+
+var _current_value_provider :ValueProvider
+var _current_failure_message :String = ""
+var _custom_failure_message :String = ""
+var _additional_failure_message: String = ""
+var _line_number := -1
+var _timeout := DEFAULT_TIMEOUT
+var _interrupted := false
+var _sleep_timer :Timer = null
+
+
+func _init(instance :Object, func_name :String, args := Array()) -> void:
+ _line_number = GdUnitAssertions.get_line_number()
+ GdAssertReports.reset_last_error_line_number()
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ # verify at first the function name exists
+ if not instance.has_method(func_name):
+ @warning_ignore("return_value_discarded")
+ report_error("The function '%s' do not exists checked instance '%s'." % [func_name, instance])
+ _interrupted = true
+ else:
+ _current_value_provider = CallBackValueProvider.new(instance, func_name, args)
+
+
+func _notification(what :int) -> void:
+ if what == NOTIFICATION_PREDELETE:
+ _interrupted = true
+ var main_node :Node = (Engine.get_main_loop() as SceneTree).root
+ if is_instance_valid(_current_value_provider):
+ _current_value_provider.dispose()
+ _current_value_provider = null
+ if is_instance_valid(_sleep_timer):
+ _sleep_timer.set_wait_time(0.0001)
+ _sleep_timer.stop()
+ main_node.remove_child(_sleep_timer)
+ _sleep_timer.free()
+ _sleep_timer = null
+
+
+func report_success() -> GdUnitFuncAssert:
+ GdAssertReports.report_success()
+ return self
+
+
+func report_error(failure :String) -> GdUnitFuncAssert:
+ _current_failure_message = GdAssertMessages.build_failure_message(failure, _additional_failure_message, _custom_failure_message)
+ GdAssertReports.report_error(_current_failure_message, _line_number)
+ return self
+
+
+func failure_message() -> String:
+ return _current_failure_message
+
+
+func override_failure_message(message: String) -> GdUnitFuncAssert:
+ _custom_failure_message = message
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitFuncAssert:
+ _additional_failure_message = message
+ return self
+
+
+func wait_until(timeout := 2000) -> GdUnitFuncAssert:
+ if timeout <= 0:
+ push_warning("Invalid timeout param, alloed timeouts must be grater than 0. Use default timeout instead")
+ _timeout = DEFAULT_TIMEOUT
+ else:
+ _timeout = timeout
+ return self
+
+
+func is_null() -> GdUnitFuncAssert:
+ await _validate_callback(cb_is_null)
+ return self
+
+
+func is_not_null() -> GdUnitFuncAssert:
+ await _validate_callback(cb_is_not_null)
+ return self
+
+
+func is_false() -> GdUnitFuncAssert:
+ await _validate_callback(cb_is_false)
+ return self
+
+
+func is_true() -> GdUnitFuncAssert:
+ await _validate_callback(cb_is_true)
+ return self
+
+
+func is_equal(expected: Variant) -> GdUnitFuncAssert:
+ await _validate_callback(cb_is_equal, expected)
+ return self
+
+
+func is_not_equal(expected: Variant) -> GdUnitFuncAssert:
+ await _validate_callback(cb_is_not_equal, expected)
+ return self
+
+
+# we need actually to define this Callable as functions otherwise we results into leaked scripts here
+# this is actually a Godot bug and needs this kind of workaround
+func cb_is_null(c :Variant, _e :Variant) -> bool: return c == null
+func cb_is_not_null(c :Variant, _e :Variant) -> bool: return c != null
+func cb_is_false(c :Variant, _e :Variant) -> bool: return c == false
+func cb_is_true(c :Variant, _e :Variant) -> bool: return c == true
+func cb_is_equal(c :Variant, e :Variant) -> bool: return GdObjects.equals(c,e)
+func cb_is_not_equal(c :Variant, e :Variant) -> bool: return not GdObjects.equals(c, e)
+
+
+func do_interrupt() -> void:
+ _interrupted = true
+
+
+func _validate_callback(predicate :Callable, expected :Variant = null) -> void:
+ if _interrupted:
+ return
+ GdUnitMemoryObserver.guard_instance(self)
+ var time_scale := Engine.get_time_scale()
+ var timer := Timer.new()
+ timer.set_name("gdunit_funcassert_interrupt_timer_%d" % timer.get_instance_id())
+ var scene_tree := Engine.get_main_loop() as SceneTree
+ scene_tree.root.add_child(timer)
+ timer.add_to_group("GdUnitTimers")
+ @warning_ignore("return_value_discarded")
+ timer.timeout.connect(do_interrupt, CONNECT_DEFERRED)
+ timer.set_one_shot(true)
+ timer.start((_timeout/1000.0)*time_scale)
+ _sleep_timer = Timer.new()
+ _sleep_timer.set_name("gdunit_funcassert_sleep_timer_%d" % _sleep_timer.get_instance_id() )
+ scene_tree.root.add_child(_sleep_timer)
+
+ while true:
+ var current :Variant = await next_current_value()
+ # is interupted or predicate success
+ if _interrupted or predicate.call(current, expected):
+ break
+ if is_instance_valid(_sleep_timer):
+ _sleep_timer.start(0.05)
+ await _sleep_timer.timeout
+
+ _sleep_timer.stop()
+ await scene_tree.process_frame
+ if _interrupted:
+ # https://github.com/godotengine/godot/issues/73052
+ #var predicate_name = predicate.get_method()
+ var predicate_name :String = str(predicate).split('::')[1]
+ @warning_ignore("return_value_discarded")
+ report_error(GdAssertMessages.error_interrupted(
+ predicate_name.strip_edges().trim_prefix("cb_"),
+ expected,
+ LocalTime.elapsed(_timeout)
+ )
+ )
+ else:
+ @warning_ignore("return_value_discarded")
+ report_success()
+ _sleep_timer.free()
+ timer.free()
+ GdUnitMemoryObserver.unguard_instance(self)
+
+
+func next_current_value() -> Variant:
+ @warning_ignore("redundant_await")
+ if is_instance_valid(_current_value_provider):
+ return await _current_value_provider.get_value()
+ return "invalid value"
diff --git a/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd.uid
new file mode 100644
index 0000000..b10c045
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://cd337pgd74ed0
diff --git a/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd
new file mode 100644
index 0000000..ce5503d
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd
@@ -0,0 +1,151 @@
+extends GdUnitGodotErrorAssert
+
+var _current_failure_message := ""
+var _custom_failure_message := ""
+var _additional_failure_message := ""
+var _callable: Callable
+var _logger := GodotGdErrorMonitor.GdUnitLogger.new(true, true)
+
+
+func _init(callable: Callable) -> void:
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ GdAssertReports.reset_last_error_line_number()
+ _callable = callable
+
+
+func _execute() -> Array[ErrorLogEntry]:
+ await _callable.call()
+ return _logger.entries()
+
+
+func failure_message() -> String:
+ return _current_failure_message
+
+
+func _report_success() -> GdUnitAssert:
+ GdAssertReports.report_success()
+ return self
+
+
+func _report_error(error_message: String, failure_line_number: int = -1) -> GdUnitAssert:
+ var line_number := failure_line_number if failure_line_number != -1 else GdUnitAssertions.get_line_number()
+ _current_failure_message = GdAssertMessages.build_failure_message(error_message, _additional_failure_message, _custom_failure_message)
+ GdAssertReports.report_error(_current_failure_message, line_number)
+ return self
+
+
+func _has_log_entry(log_entries: Array[ErrorLogEntry], type: ErrorLogEntry.TYPE, error: Variant) -> bool:
+ for entry in log_entries:
+ if entry._type == type and GdObjects.equals(entry._message, error):
+ GdUnitThreadManager.get_current_context().get_execution_context().error_monitor.erase_log_entry(entry)
+ return true
+ return false
+
+
+func _to_list(log_entries: Array[ErrorLogEntry]) -> String:
+ if log_entries.is_empty():
+ return "no errors"
+
+ var values := []
+ for entry in log_entries:
+ values.append(entry)
+ return "\n".join(values)
+
+
+func is_null() -> GdUnitGodotErrorAssert:
+ return _report_error("Not implemented")
+
+
+func is_not_null() -> GdUnitGodotErrorAssert:
+ return _report_error("Not implemented")
+
+
+func is_equal(_expected: Variant) -> GdUnitGodotErrorAssert:
+ return _report_error("Not implemented")
+
+
+func is_not_equal(_expected: Variant) -> GdUnitGodotErrorAssert:
+ return _report_error("Not implemented")
+
+
+func override_failure_message(message: String) -> GdUnitGodotErrorAssert:
+ _custom_failure_message = message
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitGodotErrorAssert:
+ _additional_failure_message = message
+ return self
+
+
+func is_success() -> GdUnitGodotErrorAssert:
+ if not _validate_callable():
+ return self
+
+ var log_entries := await _execute()
+ if log_entries.is_empty():
+ return _report_success()
+ return _report_error("""
+ Expecting: no error's are ocured.
+ but found: '%s'
+ """.dedent().trim_prefix("\n") % _to_list(log_entries))
+
+
+func is_runtime_error(expected_error: Variant) -> GdUnitGodotErrorAssert:
+ if not _validate_callable():
+ return self
+
+ var result := GdUnitArgumentMatchers.is_variant_string_matching(expected_error)
+ if result.is_error():
+ return _report_error(result.error_message())
+ var log_entries := await _execute()
+ if _has_log_entry(log_entries, ErrorLogEntry.TYPE.SCRIPT_ERROR, expected_error):
+ return _report_success()
+ return _report_error("""
+ Expecting: a runtime error is triggered.
+ expected: '%s'
+ current: '%s'
+ """.dedent().trim_prefix("\n") % [expected_error, _to_list(log_entries)])
+
+
+func is_push_warning(expected_warning: Variant) -> GdUnitGodotErrorAssert:
+ if not _validate_callable():
+ return self
+
+ var result := GdUnitArgumentMatchers.is_variant_string_matching(expected_warning)
+ if result.is_error():
+ return _report_error(result.error_message())
+ var log_entries := await _execute()
+ if _has_log_entry(log_entries, ErrorLogEntry.TYPE.PUSH_WARNING, expected_warning):
+ return _report_success()
+ return _report_error("""
+ Expecting: push_warning() is called.
+ expected: '%s'
+ current: '%s'
+ """.dedent().trim_prefix("\n") % [expected_warning, _to_list(log_entries)])
+
+
+func is_push_error(expected_error: Variant) -> GdUnitGodotErrorAssert:
+ if not _validate_callable():
+ return self
+
+ var result := GdUnitArgumentMatchers.is_variant_string_matching(expected_error)
+ if result.is_error():
+ return _report_error(result.error_message())
+ var log_entries := await _execute()
+ if _has_log_entry(log_entries, ErrorLogEntry.TYPE.PUSH_ERROR, expected_error):
+ return _report_success()
+ return _report_error("""
+ Expecting: push_error() is called.
+ expected: '%s'
+ current: '%s'
+ """.dedent().trim_prefix("\n") % [expected_error, _to_list(log_entries)])
+
+
+func _validate_callable() -> bool:
+ if _callable == null or not _callable.is_valid():
+ @warning_ignore("return_value_discarded")
+ _report_error("Invalid Callable '%s'" % _callable)
+ return false
+ return true
diff --git a/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd.uid
new file mode 100644
index 0000000..8ecadb7
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://rkf7jw6mw4d4
diff --git a/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd
new file mode 100644
index 0000000..bdee249
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd
@@ -0,0 +1,166 @@
+extends GdUnitIntAssert
+
+var _base: GdUnitAssertImpl
+
+
+func _init(current :Variant) -> void:
+ _base = GdUnitAssertImpl.new(current)
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ if not GdUnitAssertions.validate_value_type(current, TYPE_INT):
+ @warning_ignore("return_value_discarded")
+ report_error("GdUnitIntAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current))
+
+
+func _notification(event :int) -> void:
+ if event == NOTIFICATION_PREDELETE:
+ if _base != null:
+ _base.notification(event)
+ _base = null
+
+
+func current_value() -> Variant:
+ return _base.current_value()
+
+
+func report_success() -> GdUnitIntAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_success()
+ return self
+
+
+func report_error(error :String) -> GdUnitIntAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_error(error)
+ return self
+
+
+func failure_message() -> String:
+ return _base.failure_message()
+
+
+func override_failure_message(message: String) -> GdUnitIntAssert:
+ @warning_ignore("return_value_discarded")
+ _base.override_failure_message(message)
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitIntAssert:
+ @warning_ignore("return_value_discarded")
+ _base.append_failure_message(message)
+ return self
+
+
+func is_null() -> GdUnitIntAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_null()
+ return self
+
+
+func is_not_null() -> GdUnitIntAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_null()
+ return self
+
+
+func is_equal(expected: Variant) -> GdUnitIntAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_equal(expected)
+ return self
+
+
+func is_not_equal(expected: Variant) -> GdUnitIntAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_equal(expected)
+ return self
+
+
+func is_less(expected :int) -> GdUnitIntAssert:
+ var current :Variant = current_value()
+ if current == null or current >= expected:
+ return report_error(GdAssertMessages.error_is_value(Comparator.LESS_THAN, current, expected))
+ return report_success()
+
+
+func is_less_equal(expected :int) -> GdUnitIntAssert:
+ var current :Variant = current_value()
+ if current == null or current > expected:
+ return report_error(GdAssertMessages.error_is_value(Comparator.LESS_EQUAL, current, expected))
+ return report_success()
+
+
+func is_greater(expected :int) -> GdUnitIntAssert:
+ var current :Variant = current_value()
+ if current == null or current <= expected:
+ return report_error(GdAssertMessages.error_is_value(Comparator.GREATER_THAN, current, expected))
+ return report_success()
+
+
+func is_greater_equal(expected :int) -> GdUnitIntAssert:
+ var current :Variant = current_value()
+ if current == null or current < expected:
+ return report_error(GdAssertMessages.error_is_value(Comparator.GREATER_EQUAL, current, expected))
+ return report_success()
+
+
+func is_even() -> GdUnitIntAssert:
+ var current :Variant = current_value()
+ if current == null or current % 2 != 0:
+ return report_error(GdAssertMessages.error_is_even(current))
+ return report_success()
+
+
+func is_odd() -> GdUnitIntAssert:
+ var current :Variant = current_value()
+ if current == null or current % 2 == 0:
+ return report_error(GdAssertMessages.error_is_odd(current))
+ return report_success()
+
+
+func is_negative() -> GdUnitIntAssert:
+ var current :Variant = current_value()
+ if current == null or current >= 0:
+ return report_error(GdAssertMessages.error_is_negative(current))
+ return report_success()
+
+
+func is_not_negative() -> GdUnitIntAssert:
+ var current :Variant = current_value()
+ if current == null or current < 0:
+ return report_error(GdAssertMessages.error_is_not_negative(current))
+ return report_success()
+
+
+func is_zero() -> GdUnitIntAssert:
+ var current :Variant = current_value()
+ if current != 0:
+ return report_error(GdAssertMessages.error_is_zero(current))
+ return report_success()
+
+
+func is_not_zero() -> GdUnitIntAssert:
+ var current :Variant= current_value()
+ if current == 0:
+ return report_error(GdAssertMessages.error_is_not_zero())
+ return report_success()
+
+
+func is_in(expected :Array) -> GdUnitIntAssert:
+ var current :Variant = current_value()
+ if not expected.has(current):
+ return report_error(GdAssertMessages.error_is_in(current, expected))
+ return report_success()
+
+
+func is_not_in(expected :Array) -> GdUnitIntAssert:
+ var current :Variant = current_value()
+ if expected.has(current):
+ return report_error(GdAssertMessages.error_is_not_in(current, expected))
+ return report_success()
+
+
+func is_between(from :int, to :int) -> GdUnitIntAssert:
+ var current :Variant = current_value()
+ if current == null or current < from or current > to:
+ return report_error(GdAssertMessages.error_is_value(Comparator.BETWEEN_EQUAL, current, from, to))
+ return report_success()
diff --git a/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd.uid
new file mode 100644
index 0000000..512cc48
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://dbms8smcdukg0
diff --git a/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd
new file mode 100644
index 0000000..955e1ef
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd
@@ -0,0 +1,166 @@
+extends GdUnitObjectAssert
+
+var _base: GdUnitAssertImpl
+
+
+func _init(current: Variant) -> void:
+ _base = GdUnitAssertImpl.new(current)
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ if (current != null
+ and (GdUnitAssertions.validate_value_type(current, TYPE_BOOL)
+ or GdUnitAssertions.validate_value_type(current, TYPE_INT)
+ or GdUnitAssertions.validate_value_type(current, TYPE_FLOAT)
+ or GdUnitAssertions.validate_value_type(current, TYPE_STRING))):
+ @warning_ignore("return_value_discarded")
+ report_error("GdUnitObjectAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current))
+
+
+func _notification(event: int) -> void:
+ if event == NOTIFICATION_PREDELETE:
+ if _base != null:
+ _base.notification(event)
+ _base = null
+
+
+func current_value() -> Variant:
+ return _base.current_value()
+
+
+func report_success() -> GdUnitObjectAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_success()
+ return self
+
+
+func report_error(error: String) -> GdUnitObjectAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_error(error)
+ return self
+
+
+func failure_message() -> String:
+ return _base.failure_message()
+
+
+func override_failure_message(message: String) -> GdUnitObjectAssert:
+ @warning_ignore("return_value_discarded")
+ _base.override_failure_message(message)
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitObjectAssert:
+ @warning_ignore("return_value_discarded")
+ _base.append_failure_message(message)
+ return self
+
+
+func is_equal(expected: Variant) -> GdUnitObjectAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_equal(expected)
+ return self
+
+
+func is_not_equal(expected: Variant) -> GdUnitObjectAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_equal(expected)
+ return self
+
+
+func is_null() -> GdUnitObjectAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_null()
+ return self
+
+
+func is_not_null() -> GdUnitObjectAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_null()
+ return self
+
+
+@warning_ignore("shadowed_global_identifier")
+func is_same(expected: Variant) -> GdUnitObjectAssert:
+ var current: Variant = current_value()
+ if not is_same(current, expected):
+ return report_error(GdAssertMessages.error_is_same(current, expected))
+ return report_success()
+
+
+func is_not_same(expected: Variant) -> GdUnitObjectAssert:
+ var current: Variant = current_value()
+ if is_same(current, expected):
+ return report_error(GdAssertMessages.error_not_same(current, expected))
+ return report_success()
+
+
+func is_instanceof(type: Variant) -> GdUnitObjectAssert:
+ var current: Variant = current_value()
+ if current == null or not is_instance_of(current, type):
+ var result_expected := GdObjects.extract_class_name(type)
+ var result_current := GdObjects.extract_class_name(current)
+ return report_error(GdAssertMessages.error_is_instanceof(result_current, result_expected))
+ return report_success()
+
+
+func is_not_instanceof(type: Variant) -> GdUnitObjectAssert:
+ var current: Variant = current_value()
+ if is_instance_of(current, type):
+ var result := GdObjects.extract_class_name(type)
+ if result.is_success():
+ return report_error("Expected not be a instance of <%s>" % str(result.value()))
+
+ push_error("Internal ERROR: %s" % result.error_message())
+ return self
+ return report_success()
+
+
+## Checks whether the current object inherits from the specified type.
+func is_inheriting(type: Variant) -> GdUnitObjectAssert:
+ var current: Variant = current_value()
+ if not is_instance_of(current, TYPE_OBJECT):
+ return report_error("Expected '%s' to inherit from at least Object." % str(current))
+ var result := _inherits(current, type)
+ if result.is_success():
+ return report_success()
+ return report_error(result.error_message())
+
+
+## Checks whether the current object does NOT inherit from the specified type.
+func is_not_inheriting(type: Variant) -> GdUnitObjectAssert:
+ var current: Variant = current_value()
+ if not is_instance_of(current, TYPE_OBJECT):
+ return report_error("Expected '%s' to inherit from at least Object." % str(current))
+ var result := _inherits(current, type)
+ if result.is_success():
+ return report_error("Expected type to not inherit from <%s>" % _extract_class_type(type))
+ return report_success()
+
+
+func _inherits(current: Variant, type: Variant) -> GdUnitResult:
+ var type_as_string := _extract_class_type(type)
+ if type_as_string == "Object":
+ return GdUnitResult.success("")
+
+ var obj: Object = current
+ for p in obj.get_property_list():
+ var clazz_name :String = p["name"]
+ if p["usage"] == PROPERTY_USAGE_CATEGORY and clazz_name == p["hint_string"] and clazz_name == type_as_string:
+ return GdUnitResult.success("")
+ var script: Script = obj.get_script()
+ if script != null:
+ while script != null:
+ var result := GdObjects.extract_class_name(script)
+ if result.is_success() and result.value() == type_as_string:
+ return GdUnitResult.success("")
+ script = script.get_base_script()
+ return GdUnitResult.error("Expected type to inherit from <%s>" % type_as_string)
+
+
+func _extract_class_type(type: Variant) -> String:
+ if type is String:
+ return type
+ var result := GdObjects.extract_class_name(type)
+ if result.is_error():
+ return ""
+ return result.value()
diff --git a/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd.uid
new file mode 100644
index 0000000..349ff7b
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://biqm7ejvhb4oc
diff --git a/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd
new file mode 100644
index 0000000..98a6768
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd
@@ -0,0 +1,128 @@
+extends GdUnitResultAssert
+
+var _base: GdUnitAssertImpl
+
+
+func _init(current :Variant) -> void:
+ _base = GdUnitAssertImpl.new(current)
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ if not validate_value_type(current):
+ @warning_ignore("return_value_discarded")
+ report_error("GdUnitResultAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current))
+
+
+func _notification(event :int) -> void:
+ if event == NOTIFICATION_PREDELETE:
+ if _base != null:
+ _base.notification(event)
+ _base = null
+
+
+func validate_value_type(value :Variant) -> bool:
+ return value == null or value is GdUnitResult
+
+
+func current_value() -> GdUnitResult:
+ return _base.current_value()
+
+
+func report_success() -> GdUnitResultAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_success()
+ return self
+
+
+func report_error(error :String) -> GdUnitResultAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_error(error)
+ return self
+
+
+func failure_message() -> String:
+ return _base.failure_message()
+
+
+func override_failure_message(message: String) -> GdUnitResultAssert:
+ @warning_ignore("return_value_discarded")
+ _base.override_failure_message(message)
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitResultAssert:
+ @warning_ignore("return_value_discarded")
+ _base.append_failure_message(message)
+ return self
+
+
+func is_null() -> GdUnitResultAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_null()
+ return self
+
+
+func is_not_null() -> GdUnitResultAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_null()
+ return self
+
+
+func is_equal(expected: Variant) -> GdUnitResultAssert:
+ return is_value(expected)
+
+
+func is_not_equal(expected: Variant) -> GdUnitResultAssert:
+ var result := current_value()
+ var value :Variant = null if result == null else result.value()
+ if GdObjects.equals(value, expected):
+ return report_error(GdAssertMessages.error_not_equal(value, expected))
+ return report_success()
+
+
+func is_empty() -> GdUnitResultAssert:
+ var result := current_value()
+ if result == null or not result.is_empty():
+ return report_error(GdAssertMessages.error_result_is_empty(result))
+ return report_success()
+
+
+func is_success() -> GdUnitResultAssert:
+ var result := current_value()
+ if result == null or not result.is_success():
+ return report_error(GdAssertMessages.error_result_is_success(result))
+ return report_success()
+
+
+func is_warning() -> GdUnitResultAssert:
+ var result := current_value()
+ if result == null or not result.is_warn():
+ return report_error(GdAssertMessages.error_result_is_warning(result))
+ return report_success()
+
+
+func is_error() -> GdUnitResultAssert:
+ var result := current_value()
+ if result == null or not result.is_error():
+ return report_error(GdAssertMessages.error_result_is_error(result))
+ return report_success()
+
+
+func contains_message(expected :String) -> GdUnitResultAssert:
+ var result := current_value()
+ if result == null:
+ return report_error(GdAssertMessages.error_result_has_message("<null>", expected))
+ if result.is_success():
+ return report_error(GdAssertMessages.error_result_has_message_on_success(expected))
+ if result.is_error() and result.error_message() != expected:
+ return report_error(GdAssertMessages.error_result_has_message(result.error_message(), expected))
+ if result.is_warn() and result.warn_message() != expected:
+ return report_error(GdAssertMessages.error_result_has_message(result.warn_message(), expected))
+ return report_success()
+
+
+func is_value(expected: Variant) -> GdUnitResultAssert:
+ var result := current_value()
+ var value :Variant = null if result == null else result.value()
+ if not GdObjects.equals(value, expected):
+ return report_error(GdAssertMessages.error_result_is_value(value, expected))
+ return report_success()
diff --git a/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd.uid
new file mode 100644
index 0000000..6ba023e
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://bpeci78q50dtq
diff --git a/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd
new file mode 100644
index 0000000..0da696a
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd
@@ -0,0 +1,172 @@
+extends GdUnitSignalAssert
+
+const DEFAULT_TIMEOUT := 2000
+
+var _signal_collector :GdUnitSignalCollector
+var _emitter :Object
+var _current_failure_message :String = ""
+var _custom_failure_message :String = ""
+var _additional_failure_message: String = ""
+var _line_number := -1
+var _timeout := DEFAULT_TIMEOUT
+var _interrupted := false
+
+
+func _init(emitter :Object) -> void:
+ # save the actual assert instance on the current thread context
+ var context := GdUnitThreadManager.get_current_context()
+ context.set_assert(self)
+ _signal_collector = context.get_signal_collector()
+ _line_number = GdUnitAssertions.get_line_number()
+ _emitter = emitter
+ GdAssertReports.reset_last_error_line_number()
+
+
+func _notification(what :int) -> void:
+ if what == NOTIFICATION_PREDELETE:
+ _interrupted = true
+ if is_instance_valid(_emitter):
+ _signal_collector.unregister_emitter(_emitter)
+ _emitter = null
+
+
+func report_success() -> GdUnitAssert:
+ GdAssertReports.report_success()
+ return self
+
+
+func report_warning(message :String) -> GdUnitAssert:
+ GdAssertReports.report_warning(message, GdUnitAssertions.get_line_number())
+ return self
+
+
+func report_error(failure :String) -> GdUnitAssert:
+ _current_failure_message = GdAssertMessages.build_failure_message(failure, _additional_failure_message, _custom_failure_message)
+ GdAssertReports.report_error(_current_failure_message, _line_number)
+ return self
+
+
+func failure_message() -> String:
+ return _current_failure_message
+
+
+func override_failure_message(message: String) -> GdUnitSignalAssert:
+ _custom_failure_message = message
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitSignalAssert:
+ _additional_failure_message = message
+ return self
+
+
+func wait_until(timeout := 2000) -> GdUnitSignalAssert:
+ if timeout <= 0:
+ @warning_ignore("return_value_discarded")
+ report_warning("Invalid timeout parameter, allowed timeouts must be greater than 0, use default timeout instead!")
+ _timeout = DEFAULT_TIMEOUT
+ else:
+ _timeout = timeout
+ return self
+
+
+func is_null() -> GdUnitSignalAssert:
+ if _emitter != null:
+ return report_error(GdAssertMessages.error_is_null(_emitter))
+ return report_success()
+
+
+func is_not_null() -> GdUnitSignalAssert:
+ if _emitter == null:
+ return report_error(GdAssertMessages.error_is_not_null())
+ return report_success()
+
+
+func is_equal(_expected: Variant) -> GdUnitSignalAssert:
+ return report_error("Not implemented")
+
+
+func is_not_equal(_expected: Variant) -> GdUnitSignalAssert:
+ return report_error("Not implemented")
+
+
+# Verifies the signal exists checked the emitter
+func is_signal_exists(signal_or_name: Variant) -> GdUnitSignalAssert:
+ if not (signal_or_name is String or signal_or_name is Signal):
+ return report_error("Invalid signal_name: expected String or Signal, but is '%s'" % type_string(typeof(signal_or_name)))
+
+ var signal_name := _to_signal_name(signal_or_name)
+
+ if not _emitter.has_signal(signal_name):
+ @warning_ignore("return_value_discarded")
+ report_error("The signal '%s' not exists checked object '%s'." % [signal_name, _emitter.get_class()])
+ return self
+
+
+# Verifies that given signal is emitted until waiting time
+func is_emitted(signal_name: Variant, ...signal_args: Array) -> GdUnitSignalAssert:
+ _line_number = GdUnitAssertions.get_line_number()
+ @warning_ignore("unsafe_call_argument")
+ return await _wail_until_signal(
+ signal_name,
+ _wrap_arguments.callv(signal_args),
+ false)
+
+
+# Verifies that given signal is NOT emitted until waiting time
+func is_not_emitted(signal_name: Variant, ...signal_args: Array) -> GdUnitSignalAssert:
+ _line_number = GdUnitAssertions.get_line_number()
+ @warning_ignore("unsafe_call_argument")
+ return await _wail_until_signal(
+ signal_name,
+ _wrap_arguments.callv(signal_args),
+ true)
+
+
+func _wrap_arguments(...args: Array) -> Array:
+ # Check using old syntax
+ if not args.is_empty() and args[0] is Array:
+ return args[0]
+ return args
+
+
+func _wail_until_signal(signal_or_name: Variant, expected_args: Array, expect_not_emitted: bool) -> GdUnitSignalAssert:
+ if _emitter == null:
+ return report_error("Can't wait for signal checked a NULL object.")
+ if not (signal_or_name is String or signal_or_name is Signal):
+ return report_error("Invalid signal_name: expected String or Signal, but is '%s'" % type_string(typeof(signal_or_name)))
+
+ var signal_name := _to_signal_name(signal_or_name)
+ # first verify the signal is defined
+ if not _emitter.has_signal(signal_name):
+ return report_error("Can't wait for non-existion signal '%s' checked object '%s'." % [signal_name,_emitter.get_class()])
+ _signal_collector.register_emitter(_emitter)
+ var time_scale := Engine.get_time_scale()
+ var timer := Timer.new()
+ (Engine.get_main_loop() as SceneTree).root.add_child(timer)
+ timer.add_to_group("GdUnitTimers")
+ timer.set_one_shot(true)
+ @warning_ignore("return_value_discarded")
+ timer.timeout.connect(func on_timeout() -> void: _interrupted = true)
+ timer.start((_timeout/1000.0)*time_scale)
+ var is_signal_emitted := false
+ while not _interrupted and not is_signal_emitted:
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ if is_instance_valid(_emitter):
+ is_signal_emitted = _signal_collector.match(_emitter, signal_name, expected_args)
+ if is_signal_emitted and expect_not_emitted:
+ @warning_ignore("return_value_discarded")
+ report_error(GdAssertMessages.error_signal_emitted(signal_name, expected_args, LocalTime.elapsed(int(_timeout-timer.time_left*1000))))
+
+ if _interrupted and not expect_not_emitted:
+ @warning_ignore("return_value_discarded")
+ report_error(GdAssertMessages.error_wait_signal(signal_name, expected_args, LocalTime.elapsed(_timeout)))
+ timer.free()
+ if is_instance_valid(_emitter):
+ _signal_collector.reset_received_signals(_emitter, signal_name, expected_args)
+ return self
+
+
+func _to_signal_name(signal_or_name: Variant) -> String:
+ @warning_ignore("unsafe_cast")
+ return (signal_or_name as Signal).get_name() if signal_or_name is Signal else signal_or_name
diff --git a/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd.uid
new file mode 100644
index 0000000..3690588
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://n33x4jg0yjqh
diff --git a/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd
new file mode 100644
index 0000000..cdbcdff
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd
@@ -0,0 +1,208 @@
+extends GdUnitStringAssert
+
+var _base: GdUnitAssertImpl
+
+
+func _init(current :Variant) -> void:
+ _base = GdUnitAssertImpl.new(current)
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ if current != null and typeof(current) != TYPE_STRING and typeof(current) != TYPE_STRING_NAME:
+ @warning_ignore("return_value_discarded")
+ report_error("GdUnitStringAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current))
+
+
+func _notification(event :int) -> void:
+ if event == NOTIFICATION_PREDELETE:
+ if _base != null:
+ _base.notification(event)
+ _base = null
+
+
+func failure_message() -> String:
+ return _base.failure_message()
+
+
+func current_value() -> Variant:
+ return _base.current_value()
+
+
+func report_success() -> GdUnitStringAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_success()
+ return self
+
+
+func report_error(error :String) -> GdUnitStringAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_error(error)
+ return self
+
+
+func override_failure_message(message: String) -> GdUnitStringAssert:
+ @warning_ignore("return_value_discarded")
+ _base.override_failure_message(message)
+ return self
+
+
+func append_failure_message(message: String) -> GdUnitStringAssert:
+ @warning_ignore("return_value_discarded")
+ _base.append_failure_message(message)
+ return self
+
+
+func is_null() -> GdUnitStringAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_null()
+ return self
+
+
+func is_not_null() -> GdUnitStringAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_null()
+ return self
+
+
+func is_equal(expected: Variant) -> GdUnitStringAssert:
+ return _is_equal(expected, false, GdAssertMessages.error_equal)
+
+
+func is_equal_ignoring_case(expected: Variant) -> GdUnitStringAssert:
+ return _is_equal(expected, true, GdAssertMessages.error_equal_ignoring_case)
+
+
+@warning_ignore_start("unsafe_call_argument")
+func _is_equal(expected: Variant, ignore_case: bool, message_cb: Callable) -> GdUnitStringAssert:
+ var current: Variant = current_value()
+ if current == null:
+ return report_error(message_cb.call(current, expected))
+ var cur_value := str(current)
+ if not GdObjects.equals(cur_value, expected, ignore_case):
+ var exp_value := str(expected)
+ if contains_bbcode(cur_value):
+ # mask user bbcode
+ # https://docs.godotengine.org/en/4.5/tutorials/ui/bbcode_in_richtextlabel.html#handling-user-input-safely
+ return report_error(message_cb.call(cur_value.replace("[", "[lb]"), exp_value.replace("[", "[lb]")))
+ var diffs := GdDiffTool.string_diff(cur_value, exp_value)
+ var formatted_current := GdAssertMessages.colored_array_div(diffs[1])
+ return report_error(message_cb.call(formatted_current, exp_value))
+ return report_success()
+@warning_ignore_restore("unsafe_call_argument")
+
+
+func is_not_equal(expected: Variant) -> GdUnitStringAssert:
+ var current: Variant = current_value()
+ if GdObjects.equals(current, expected):
+ return report_error(GdAssertMessages.error_not_equal(current, expected))
+ return report_success()
+
+
+func is_not_equal_ignoring_case(expected :Variant) -> GdUnitStringAssert:
+ var current :Variant = current_value()
+ if GdObjects.equals(current, expected, true):
+ return report_error(GdAssertMessages.error_not_equal(current, expected))
+ return report_success()
+
+
+func is_empty() -> GdUnitStringAssert:
+ var current :Variant = current_value()
+ @warning_ignore("unsafe_cast")
+ if current == null or not (current as String).is_empty():
+ return report_error(GdAssertMessages.error_is_empty(current))
+ return report_success()
+
+
+func is_not_empty() -> GdUnitStringAssert:
+ var current :Variant = current_value()
+ @warning_ignore("unsafe_cast")
+ if current == null or (current as String).is_empty():
+ return report_error(GdAssertMessages.error_is_not_empty())
+ return report_success()
+
+
+func contains(expected :String) -> GdUnitStringAssert:
+ var current :Variant = current_value()
+ @warning_ignore("unsafe_cast")
+ if current == null or (current as String).find(expected) == -1:
+ return report_error(GdAssertMessages.error_contains(current, expected))
+ return report_success()
+
+
+func not_contains(expected :String) -> GdUnitStringAssert:
+ var current :Variant = current_value()
+ @warning_ignore("unsafe_cast")
+ if current != null and (current as String).find(expected) != -1:
+ return report_error(GdAssertMessages.error_not_contains(current, expected))
+ return report_success()
+
+
+func contains_ignoring_case(expected :String) -> GdUnitStringAssert:
+ var current :Variant = current_value()
+ @warning_ignore("unsafe_cast")
+ if current == null or (current as String).findn(expected) == -1:
+ return report_error(GdAssertMessages.error_contains_ignoring_case(current, expected))
+ return report_success()
+
+
+func not_contains_ignoring_case(expected :String) -> GdUnitStringAssert:
+ var current :Variant = current_value()
+ @warning_ignore("unsafe_cast")
+ if current != null and (current as String).findn(expected) != -1:
+ return report_error(GdAssertMessages.error_not_contains_ignoring_case(current, expected))
+ return report_success()
+
+
+func starts_with(expected :String) -> GdUnitStringAssert:
+ var current :Variant = current_value()
+ @warning_ignore("unsafe_cast")
+ if current == null or (current as String).find(expected) != 0:
+ return report_error(GdAssertMessages.error_starts_with(current, expected))
+ return report_success()
+
+
+func ends_with(expected :String) -> GdUnitStringAssert:
+ var current :Variant = current_value()
+ if current == null:
+ return report_error(GdAssertMessages.error_ends_with(current, expected))
+ @warning_ignore("unsafe_cast")
+ var find :int = (current as String).length() - expected.length()
+ @warning_ignore("unsafe_cast")
+ if (current as String).rfind(expected) != find:
+ return report_error(GdAssertMessages.error_ends_with(current, expected))
+ return report_success()
+
+
+# gdlint:disable=max-returns
+func has_length(expected :int, comparator := Comparator.EQUAL) -> GdUnitStringAssert:
+ var current :Variant = current_value()
+ if current == null:
+ return report_error(GdAssertMessages.error_has_length(current, expected, comparator))
+ var str_current: String = current
+ match comparator:
+ Comparator.EQUAL:
+ if str_current.length() != expected:
+ return report_error(GdAssertMessages.error_has_length(str_current, expected, comparator))
+ Comparator.LESS_THAN:
+ if str_current.length() >= expected:
+ return report_error(GdAssertMessages.error_has_length(str_current, expected, comparator))
+ Comparator.LESS_EQUAL:
+ if str_current.length() > expected:
+ return report_error(GdAssertMessages.error_has_length(str_current, expected, comparator))
+ Comparator.GREATER_THAN:
+ if str_current.length() <= expected:
+ return report_error(GdAssertMessages.error_has_length(str_current, expected, comparator))
+ Comparator.GREATER_EQUAL:
+ if str_current.length() < expected:
+ return report_error(GdAssertMessages.error_has_length(str_current, expected, comparator))
+ _:
+ return report_error("Comparator '%d' not implemented!" % comparator)
+ return report_success()
+
+
+func contains_bbcode(value: String) -> bool:
+ var rtl := RichTextLabel.new()
+ rtl.bbcode_enabled = true
+ rtl.parse_bbcode(value)
+ var has_bbcode := rtl.get_parsed_text() != value
+ rtl.free()
+ return has_bbcode
diff --git a/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd.uid
new file mode 100644
index 0000000..d257a25
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://cp0rvxafx8aqw
diff --git a/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd
new file mode 100644
index 0000000..fbc031a
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd
@@ -0,0 +1,187 @@
+extends GdUnitVectorAssert
+
+var _base: GdUnitAssertImpl
+var _current_type: int
+var _type_check: bool
+
+func _init(current: Variant, type_check := true) -> void:
+ _type_check = type_check
+ _base = GdUnitAssertImpl.new(current)
+ # save the actual assert instance on the current thread context
+ GdUnitThreadManager.get_current_context().set_assert(self)
+ if not _validate_value_type(current):
+ @warning_ignore("return_value_discarded")
+ report_error("GdUnitVectorAssert error, the type <%s> is not supported." % GdObjects.typeof_as_string(current))
+ _current_type = typeof(current)
+
+
+func _notification(event :int) -> void:
+ if event == NOTIFICATION_PREDELETE:
+ if _base != null:
+ _base.notification(event)
+ _base = null
+
+
+func _validate_value_type(value :Variant) -> bool:
+ return (
+ value == null
+ or typeof(value) in [
+ TYPE_VECTOR2,
+ TYPE_VECTOR2I,
+ TYPE_VECTOR3,
+ TYPE_VECTOR3I,
+ TYPE_VECTOR4,
+ TYPE_VECTOR4I
+ ]
+ )
+
+
+func _validate_is_vector_type(value :Variant) -> bool:
+ var type := typeof(value)
+ if type == _current_type or _current_type == TYPE_NIL:
+ return true
+ @warning_ignore("return_value_discarded")
+ report_error(GdAssertMessages.error_is_wrong_type(_current_type, type))
+ return false
+
+
+func current_value() -> Variant:
+ return _base.current_value()
+
+
+func report_success() -> GdUnitVectorAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_success()
+ return self
+
+
+func report_error(error :String) -> GdUnitVectorAssert:
+ @warning_ignore("return_value_discarded")
+ _base.report_error(error)
+ return self
+
+
+func failure_message() -> String:
+ return _base.failure_message()
+
+
+func override_failure_message(message: String) -> GdUnitVectorAssert:
+ @warning_ignore("return_value_discarded")
+ _base.override_failure_message(message)
+ return self
+
+
+func append_failure_message(message :String) -> GdUnitVectorAssert:
+ @warning_ignore("return_value_discarded")
+ _base.append_failure_message(message)
+ return self
+
+
+func is_null() -> GdUnitVectorAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_null()
+ return self
+
+
+func is_not_null() -> GdUnitVectorAssert:
+ @warning_ignore("return_value_discarded")
+ _base.is_not_null()
+ return self
+
+
+func is_equal(expected: Variant) -> GdUnitVectorAssert:
+ if _type_check and not _validate_is_vector_type(expected):
+ return self
+ @warning_ignore("return_value_discarded")
+ _base.is_equal(expected)
+ return self
+
+
+func is_not_equal(expected: Variant) -> GdUnitVectorAssert:
+ if _type_check and not _validate_is_vector_type(expected):
+ return self
+ @warning_ignore("return_value_discarded")
+ _base.is_not_equal(expected)
+ return self
+
+
+@warning_ignore("shadowed_global_identifier")
+func is_equal_approx(expected :Variant, approx :Variant) -> GdUnitVectorAssert:
+ if not _validate_is_vector_type(expected) or not _validate_is_vector_type(approx):
+ return self
+ var current :Variant = current_value()
+ var from :Variant = expected - approx
+ var to :Variant = expected + approx
+ if current == null or (not _is_equal_approx(current, from, to)):
+ return report_error(GdAssertMessages.error_is_value(Comparator.BETWEEN_EQUAL, current, from, to))
+ return report_success()
+
+
+func _is_equal_approx(current :Variant, from :Variant, to :Variant) -> bool:
+ match typeof(current):
+ TYPE_VECTOR2, TYPE_VECTOR2I:
+ return ((current.x >= from.x and current.y >= from.y)
+ and (current.x <= to.x and current.y <= to.y))
+ TYPE_VECTOR3, TYPE_VECTOR3I:
+ return ((current.x >= from.x and current.y >= from.y and current.z >= from.z)
+ and (current.x <= to.x and current.y <= to.y and current.z <= to.z))
+ TYPE_VECTOR4, TYPE_VECTOR4I:
+ return ((current.x >= from.x and current.y >= from.y and current.z >= from.z and current.w >= from.w)
+ and (current.x <= to.x and current.y <= to.y and current.z <= to.z and current.w <= to.w))
+ _:
+ push_error("Missing implementation '_is_equal_approx' for vector type %s" % typeof(current))
+ return false
+
+
+func is_less(expected :Variant) -> GdUnitVectorAssert:
+ if not _validate_is_vector_type(expected):
+ return self
+ var current :Variant = current_value()
+ if current == null or current >= expected:
+ return report_error(GdAssertMessages.error_is_value(Comparator.LESS_THAN, current, expected))
+ return report_success()
+
+
+func is_less_equal(expected :Variant) -> GdUnitVectorAssert:
+ if not _validate_is_vector_type(expected):
+ return self
+ var current :Variant = current_value()
+ if current == null or current > expected:
+ return report_error(GdAssertMessages.error_is_value(Comparator.LESS_EQUAL, current, expected))
+ return report_success()
+
+
+func is_greater(expected :Variant) -> GdUnitVectorAssert:
+ if not _validate_is_vector_type(expected):
+ return self
+ var current :Variant = current_value()
+ if current == null or current <= expected:
+ return report_error(GdAssertMessages.error_is_value(Comparator.GREATER_THAN, current, expected))
+ return report_success()
+
+
+func is_greater_equal(expected :Variant) -> GdUnitVectorAssert:
+ if not _validate_is_vector_type(expected):
+ return self
+ var current :Variant = current_value()
+ if current == null or current < expected:
+ return report_error(GdAssertMessages.error_is_value(Comparator.GREATER_EQUAL, current, expected))
+ return report_success()
+
+
+func is_between(from :Variant, to :Variant) -> GdUnitVectorAssert:
+ if not _validate_is_vector_type(from) or not _validate_is_vector_type(to):
+ return self
+ var current :Variant = current_value()
+ if current == null or not (current >= from and current <= to):
+ return report_error(GdAssertMessages.error_is_value(Comparator.BETWEEN_EQUAL, current, from, to))
+ return report_success()
+
+
+func is_not_between(from :Variant, to :Variant) -> GdUnitVectorAssert:
+ if not _validate_is_vector_type(from) or not _validate_is_vector_type(to):
+ return self
+ var current :Variant = current_value()
+ if (current != null and current >= from and current <= to):
+ return report_error(GdAssertMessages.error_is_value(Comparator.NOT_BETWEEN_EQUAL, current, from, to))
+ return report_success()
diff --git a/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd.uid b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd.uid
new file mode 100644
index 0000000..4f08785
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd.uid
@@ -0,0 +1 @@
+uid://baa45s62kgmhi
diff --git a/addons/gdUnit4/src/asserts/ValueProvider.gd b/addons/gdUnit4/src/asserts/ValueProvider.gd
new file mode 100644
index 0000000..be01f70
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/ValueProvider.gd
@@ -0,0 +1,10 @@
+# base interface for assert value provider
+class_name ValueProvider
+extends RefCounted
+
+func get_value() -> Variant:
+ return null
+
+
+func dispose() -> void:
+ pass
diff --git a/addons/gdUnit4/src/asserts/ValueProvider.gd.uid b/addons/gdUnit4/src/asserts/ValueProvider.gd.uid
new file mode 100644
index 0000000..f9e818d
--- /dev/null
+++ b/addons/gdUnit4/src/asserts/ValueProvider.gd.uid
@@ -0,0 +1 @@
+uid://fh2vdv250867
diff --git a/addons/gdUnit4/src/cmd/CmdArgumentParser.gd b/addons/gdUnit4/src/cmd/CmdArgumentParser.gd
new file mode 100644
index 0000000..aa02319
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdArgumentParser.gd
@@ -0,0 +1,62 @@
+class_name CmdArgumentParser
+extends RefCounted
+
+var _options :CmdOptions
+var _tool_name :String
+var _parsed_commands :Dictionary = Dictionary()
+
+
+func _init(p_options :CmdOptions, p_tool_name :String) -> void:
+ _options = p_options
+ _tool_name = p_tool_name
+
+
+func parse(args :Array, ignore_unknown_cmd := false) -> GdUnitResult:
+ _parsed_commands.clear()
+
+ # parse until first program argument
+ while not args.is_empty():
+ var arg :String = args.pop_front()
+ if arg.find(_tool_name) != -1:
+ break
+
+ if args.is_empty():
+ return GdUnitResult.empty()
+
+ # now parse all arguments
+ while not args.is_empty():
+ var cmd :String = args.pop_front()
+ var option := _options.get_option(cmd)
+
+ if option:
+ if _parse_cmd_arguments(option, args) == -1:
+ return GdUnitResult.error("The '%s' command requires an argument!" % option.short_command())
+ elif not ignore_unknown_cmd:
+ return GdUnitResult.error("Unknown '%s' command!" % cmd)
+ return GdUnitResult.success(_parsed_commands.values())
+
+
+func options() -> CmdOptions:
+ return _options
+
+
+func _parse_cmd_arguments(option: CmdOption, args: Array) -> int:
+ var command_name := option.short_command()
+ var command: CmdCommand = _parsed_commands.get(command_name, CmdCommand.new(command_name))
+
+ if option.has_argument():
+ if not option.is_argument_optional() and args.is_empty():
+ return -1
+ if _is_next_value_argument(args):
+ var value: String = args.pop_front()
+ command.add_argument(value)
+ elif not option.is_argument_optional():
+ return -1
+ _parsed_commands[command_name] = command
+ return 0
+
+
+func _is_next_value_argument(args: PackedStringArray) -> bool:
+ if args.is_empty():
+ return false
+ return _options.get_option(args[0]) == null
diff --git a/addons/gdUnit4/src/cmd/CmdArgumentParser.gd.uid b/addons/gdUnit4/src/cmd/CmdArgumentParser.gd.uid
new file mode 100644
index 0000000..978c595
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdArgumentParser.gd.uid
@@ -0,0 +1 @@
+uid://v2okve7k32hr
diff --git a/addons/gdUnit4/src/cmd/CmdCommand.gd b/addons/gdUnit4/src/cmd/CmdCommand.gd
new file mode 100644
index 0000000..92e8c1f
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdCommand.gd
@@ -0,0 +1,27 @@
+class_name CmdCommand
+extends RefCounted
+
+var _name: String
+var _arguments: PackedStringArray
+
+
+func _init(p_name :String, p_arguments := []) -> void:
+ _name = p_name
+ _arguments = PackedStringArray(p_arguments)
+
+
+func name() -> String:
+ return _name
+
+
+func arguments() -> PackedStringArray:
+ return _arguments
+
+
+func add_argument(arg :String) -> void:
+ @warning_ignore("return_value_discarded")
+ _arguments.append(arg)
+
+
+func _to_string() -> String:
+ return "%s:%s" % [_name, ", ".join(_arguments)]
diff --git a/addons/gdUnit4/src/cmd/CmdCommand.gd.uid b/addons/gdUnit4/src/cmd/CmdCommand.gd.uid
new file mode 100644
index 0000000..28437db
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdCommand.gd.uid
@@ -0,0 +1 @@
+uid://detl386jrd80u
diff --git a/addons/gdUnit4/src/cmd/CmdCommandHandler.gd b/addons/gdUnit4/src/cmd/CmdCommandHandler.gd
new file mode 100644
index 0000000..2a7ed55
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdCommandHandler.gd
@@ -0,0 +1,136 @@
+class_name CmdCommandHandler
+extends RefCounted
+
+const CB_SINGLE_ARG = 0
+const CB_MULTI_ARGS = 1
+const NO_CB := Callable()
+
+var _cmd_options :CmdOptions
+# holds the command callbacks by key:<cmd_name>:String and value: [<cb single arg>, <cb multible args>]:Array
+# Dictionary[String, Array[Callback]
+var _command_cbs :Dictionary
+
+
+
+func _init(cmd_options: CmdOptions) -> void:
+ _cmd_options = cmd_options
+
+
+# register a callback function for given command
+# cmd_name short name of the command
+# fr_arg a funcref to a function with a single argument
+func register_cb(cmd_name: String, cb: Callable) -> CmdCommandHandler:
+ var registered_cb: Array = _command_cbs.get(cmd_name, [NO_CB, NO_CB])
+ if registered_cb[CB_SINGLE_ARG]:
+ push_error("A function for command '%s' is already registered!" % cmd_name)
+ return self
+
+ if not _validate_cb_signature(cb, TYPE_STRING):
+ push_error(
+ ("The callback '%s:%s' for command '%s' has invalid function signature. "
+ +"The callback signature must be 'func name(value: PackedStringArray)'")
+ % [cb.get_object().get_class(), cb.get_method(), cmd_name])
+ return null
+
+ registered_cb[CB_SINGLE_ARG] = cb
+ _command_cbs[cmd_name] = registered_cb
+ return self
+
+
+# register a callback function for given command
+# cb a funcref to a function with a variable number of arguments but expects all parameters to be passed via a single Array.
+func register_cbv(cmd_name: String, cb: Callable) -> CmdCommandHandler:
+ var registered_cb: Array = _command_cbs.get(cmd_name, [NO_CB, NO_CB])
+ if registered_cb[CB_MULTI_ARGS]:
+ push_error("A function for command '%s' is already registered!" % cmd_name)
+ return self
+
+ if not _validate_cb_signature(cb, TYPE_PACKED_STRING_ARRAY):
+ push_error(
+ ("The callback '%s:%s' for command '%s' has invalid function signature. "
+ +"The callback signature must be 'func name(value: PackedStringArray)'")
+ % [cb.get_object().get_class(), cb.get_method(), cmd_name])
+ return null
+
+ registered_cb[CB_MULTI_ARGS] = cb
+ _command_cbs[cmd_name] = registered_cb
+ return self
+
+
+func _validate() -> GdUnitResult:
+ var errors := PackedStringArray()
+ # Dictionary[StringName, String]
+ var registered_cbs := Dictionary()
+
+ for cmd_name in _command_cbs.keys() as Array[String]:
+ var cb: Callable = (_command_cbs[cmd_name][CB_SINGLE_ARG]
+ if _command_cbs[cmd_name][CB_SINGLE_ARG]
+ else _command_cbs[cmd_name][CB_MULTI_ARGS])
+ if cb != NO_CB and not cb.is_valid():
+ @warning_ignore("return_value_discarded")
+ errors.append("Invalid function reference for command '%s', Check the function reference!" % cmd_name)
+ if _cmd_options.get_option(cmd_name) == null:
+ @warning_ignore("return_value_discarded")
+ errors.append("The command '%s' is unknown, verify your CmdOptions!" % cmd_name)
+ # verify for multiple registered command callbacks
+ if cb != NO_CB:
+ var cb_method := cb.get_method()
+ if registered_cbs.has(cb_method):
+ var already_registered_cmd :String = registered_cbs[cb_method]
+ @warning_ignore("return_value_discarded")
+ errors.append("The function reference '%s' already registerd for command '%s'!" % [cb_method, already_registered_cmd])
+ else:
+ registered_cbs[cb_method] = cmd_name
+ if errors.is_empty():
+ return GdUnitResult.success(true)
+ return GdUnitResult.error("\n".join(errors))
+
+
+func execute(commands: Array[CmdCommand]) -> GdUnitResult:
+ var result := _validate()
+ if result.is_error():
+ return result
+ for cmd in commands:
+ var cmd_name := cmd.name()
+ if _command_cbs.has(cmd_name):
+ var cb_s: Callable = _command_cbs.get(cmd_name)[CB_SINGLE_ARG]
+ var arguments := cmd.arguments()
+ var cmd_option := _cmd_options.get_option(cmd_name)
+
+ if arguments.is_empty():
+ cb_s.call()
+ elif arguments.size() > 1:
+ var cb_m: Callable = _command_cbs.get(cmd_name)[CB_MULTI_ARGS]
+ cb_m.call(arguments)
+ else:
+ if cmd_option.type() == TYPE_BOOL:
+ cb_s.call(true if arguments[0] == "true" else false)
+ else:
+ cb_s.call(arguments[0])
+
+ return GdUnitResult.success(true)
+
+
+func _validate_cb_signature(cb: Callable, arg_type: int) -> bool:
+ for m in cb.get_object().get_method_list():
+ if m["name"] == cb.get_method():
+ @warning_ignore("unsafe_cast")
+ return _validate_func_arguments(m["args"] as Array, arg_type)
+ return true
+
+
+func _validate_func_arguments(arguments: Array, arg_type: int) -> bool:
+ # validate we have a single argument
+ if arguments.size() > 1:
+ return false
+ # a cb with no arguments is also valid
+ if arguments.size() == 0:
+ return true
+ # validate argument type
+ var arg: Dictionary = arguments[0]
+ @warning_ignore("unsafe_cast")
+ if arg["usage"] as int == PROPERTY_USAGE_NIL_IS_VARIANT:
+ return true
+ if arg["type"] != arg_type:
+ return false
+ return true
diff --git a/addons/gdUnit4/src/cmd/CmdCommandHandler.gd.uid b/addons/gdUnit4/src/cmd/CmdCommandHandler.gd.uid
new file mode 100644
index 0000000..b0fa79a
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdCommandHandler.gd.uid
@@ -0,0 +1 @@
+uid://2q800f4gbor2
diff --git a/addons/gdUnit4/src/cmd/CmdOption.gd b/addons/gdUnit4/src/cmd/CmdOption.gd
new file mode 100644
index 0000000..a4982de
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdOption.gd
@@ -0,0 +1,61 @@
+class_name CmdOption
+extends RefCounted
+
+
+var _commands :PackedStringArray
+var _help :String
+var _description :String
+var _type :int
+var _arg_optional :bool = false
+
+
+# constructs a command option by given arguments
+# commands : a string with comma separated list of available commands begining with the short form
+# help: a help text show howto use
+# description: a full description of the command
+# type: the argument type
+# arg_optional: defines of the argument optional
+func _init(p_commands :String, p_help :String, p_description :String, p_type :int = TYPE_NIL, p_arg_optional :bool = false) -> void:
+ _commands = p_commands.replace(" ", "").replace("\t", "").split(",")
+ _help = p_help
+ _description = p_description
+ _type = p_type
+ _arg_optional = p_arg_optional
+
+
+func commands() -> PackedStringArray:
+ return _commands
+
+
+func short_command() -> String:
+ return _commands[0]
+
+
+func help() -> String:
+ return _help
+
+
+func description() -> String:
+ return _description
+
+
+func type() -> int:
+ return _type
+
+
+func is_argument_optional() -> bool:
+ return _arg_optional
+
+
+func has_argument() -> bool:
+ return _type != TYPE_NIL
+
+
+func describe() -> String:
+ if help().is_empty():
+ return " %-32s %s \n" % [commands(), description()]
+ return " %-32s %s \n %-32s %s\n" % [commands(), description(), "", help()]
+
+
+func _to_string() -> String:
+ return describe()
diff --git a/addons/gdUnit4/src/cmd/CmdOption.gd.uid b/addons/gdUnit4/src/cmd/CmdOption.gd.uid
new file mode 100644
index 0000000..e4717b6
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdOption.gd.uid
@@ -0,0 +1 @@
+uid://cjlyjf7drcjtf
diff --git a/addons/gdUnit4/src/cmd/CmdOptions.gd b/addons/gdUnit4/src/cmd/CmdOptions.gd
new file mode 100644
index 0000000..c610529
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdOptions.gd
@@ -0,0 +1,31 @@
+class_name CmdOptions
+extends RefCounted
+
+
+var _default_options :Array[CmdOption]
+var _advanced_options :Array[CmdOption]
+
+
+func _init(p_options :Array[CmdOption] = [], p_advanced_options :Array[CmdOption] = []) -> void:
+ # default help options
+ _default_options = p_options
+ _advanced_options = p_advanced_options
+
+
+func default_options() -> Array[CmdOption]:
+ return _default_options
+
+
+func advanced_options() -> Array[CmdOption]:
+ return _advanced_options
+
+
+func options() -> Array[CmdOption]:
+ return default_options() + advanced_options()
+
+
+func get_option(cmd :String) -> CmdOption:
+ for option in options():
+ if Array(option.commands()).has(cmd):
+ return option
+ return null
diff --git a/addons/gdUnit4/src/cmd/CmdOptions.gd.uid b/addons/gdUnit4/src/cmd/CmdOptions.gd.uid
new file mode 100644
index 0000000..6e0c2c7
--- /dev/null
+++ b/addons/gdUnit4/src/cmd/CmdOptions.gd.uid
@@ -0,0 +1 @@
+uid://bcrrg66r74ryx
diff --git a/addons/gdUnit4/src/core/GdArrayTools.gd b/addons/gdUnit4/src/core/GdArrayTools.gd
new file mode 100644
index 0000000..74f0e17
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdArrayTools.gd
@@ -0,0 +1,127 @@
+## Small helper tool to work with Godot Arrays
+class_name GdArrayTools
+extends RefCounted
+
+
+const max_elements := 32
+const ARRAY_TYPES := [
+ TYPE_ARRAY,
+ TYPE_PACKED_BYTE_ARRAY,
+ TYPE_PACKED_INT32_ARRAY,
+ TYPE_PACKED_INT64_ARRAY,
+ TYPE_PACKED_FLOAT32_ARRAY,
+ TYPE_PACKED_FLOAT64_ARRAY,
+ TYPE_PACKED_STRING_ARRAY,
+ TYPE_PACKED_VECTOR2_ARRAY,
+ TYPE_PACKED_VECTOR3_ARRAY,
+ TYPE_PACKED_VECTOR4_ARRAY,
+ TYPE_PACKED_COLOR_ARRAY
+]
+
+
+static func is_array_type(value: Variant) -> bool:
+ return is_type_array(typeof(value))
+
+
+static func is_type_array(type :int) -> bool:
+ return type in ARRAY_TYPES
+
+
+## Filters an array by given value[br]
+## If the given value not an array it returns null, will remove all occurence of given value.
+static func filter_value(array: Variant, value: Variant) -> Variant:
+ if not is_array_type(array):
+ return null
+
+ @warning_ignore("unsafe_method_access")
+ var filtered_array: Variant = array.duplicate()
+ @warning_ignore("unsafe_method_access")
+ var index: int = filtered_array.find(value)
+ while index != -1:
+ @warning_ignore("unsafe_method_access")
+ filtered_array.remove_at(index)
+ @warning_ignore("unsafe_method_access")
+ index = filtered_array.find(value)
+ return filtered_array
+
+
+## Groups an array by a custom key selector
+## The function should take an item and return the group key
+static func group_by(array: Array, key_selector: Callable) -> Dictionary:
+ var result := {}
+
+ for item: Variant in array:
+ var group_key: Variant = key_selector.call(item)
+ var values: Array = result.get_or_add(group_key, [])
+ values.append(item)
+
+ return result
+
+
+## Erases a value from given array by using equals(l,r) to find the element to erase
+static func erase_value(array :Array, value :Variant) -> void:
+ for element :Variant in array:
+ if GdObjects.equals(element, value):
+ array.erase(element)
+
+
+## Scans for the array build in type on a untyped array[br]
+## Returns the buildin type by scan all values and returns the type if all values has the same type.
+## If the values has different types TYPE_VARIANT is returend
+static func scan_typed(array :Array) -> int:
+ if array.is_empty():
+ return TYPE_NIL
+ var actual_type := GdObjects.TYPE_VARIANT
+ for value :Variant in array:
+ var current_type := typeof(value)
+ if not actual_type in [GdObjects.TYPE_VARIANT, current_type]:
+ return GdObjects.TYPE_VARIANT
+ actual_type = current_type
+ return actual_type
+
+
+## Converts given array into a string presentation.[br]
+## This function is different to the original Godot str(<array>) implementation.
+## The string presentaion contains fullquallified typed informations.
+##[br]
+## Examples:
+## [codeblock]
+## # will result in PackedString(["a", "b"])
+## GdArrayTools.as_string(PackedStringArray("a", "b"))
+## # will result in PackedString(["a", "b"])
+## GdArrayTools.as_string(PackedColorArray(Color.RED, COLOR.GREEN))
+## [/codeblock]
+static func as_string(elements: Variant, encode_value := true) -> String:
+ var delemiter := ", "
+ if elements == null:
+ return "<null>"
+ @warning_ignore("unsafe_cast")
+ if (elements as Array).is_empty():
+ return "<empty>"
+ var prefix := _typeof_as_string(elements) if encode_value else ""
+ var formatted := ""
+ var index := 0
+ for element :Variant in elements:
+ if max_elements != -1 and index > max_elements:
+ return prefix + "[" + formatted + delemiter + "...]"
+ if formatted.length() > 0 :
+ formatted += delemiter
+ formatted += GdDefaultValueDecoder.decode(element) if encode_value else str(element)
+ index += 1
+ return prefix + "[" + formatted + "]"
+
+
+static func has_same_content(current: Array, other: Array) -> bool:
+ if current.size() != other.size(): return false
+ for element: Variant in current:
+ if not other.has(element): return false
+ if current.count(element) != other.count(element): return false
+ return true
+
+
+static func _typeof_as_string(value :Variant) -> String:
+ var type := typeof(value)
+ # for untyped array we retun empty string
+ if type == TYPE_ARRAY:
+ return ""
+ return GdObjects.typeof_as_string(value)
diff --git a/addons/gdUnit4/src/core/GdArrayTools.gd.uid b/addons/gdUnit4/src/core/GdArrayTools.gd.uid
new file mode 100644
index 0000000..ca8fecb
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdArrayTools.gd.uid
@@ -0,0 +1 @@
+uid://ojsvg5sbjqmr
diff --git a/addons/gdUnit4/src/core/GdDiffTool.gd b/addons/gdUnit4/src/core/GdDiffTool.gd
new file mode 100644
index 0000000..5131df7
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdDiffTool.gd
@@ -0,0 +1,224 @@
+# Myers' Diff Algorithm implementation
+# Based on "An O(ND) Difference Algorithm and Its Variations" by Eugene W. Myers
+class_name GdDiffTool
+extends RefCounted
+
+
+const DIV_ADD :int = 214
+const DIV_SUB :int = 215
+
+
+class Edit:
+ enum Type { EQUAL, INSERT, DELETE }
+ var type: Type
+ var character: int
+
+ func _init(t: Type, chr: int) -> void:
+ type = t
+ character = chr
+
+
+# Main entry point - returns [ldiff, rdiff]
+static func string_diff(left: Variant, right: Variant) -> Array[PackedInt32Array]:
+ var lb := PackedInt32Array() if left == null else str(left).to_utf32_buffer().to_int32_array()
+ var rb := PackedInt32Array() if right == null else str(right).to_utf32_buffer().to_int32_array()
+
+ # Early exit for identical strings
+ if lb == rb:
+ return [lb.duplicate(), rb.duplicate()]
+
+ var edits := _myers_diff(lb, rb)
+ return _edits_to_diff_format(edits)
+
+
+# Core Myers' algorithm
+static func _myers_diff(a: PackedInt32Array, b: PackedInt32Array) -> Array[Edit]:
+ var n := a.size()
+ var m := b.size()
+ var max_d := n + m
+
+ # V array stores the furthest reaching x coordinate for each k-line
+ # We need indices from -max_d to max_d, so we offset by max_d
+ var v := PackedInt32Array()
+ v.resize(2 * max_d + 1)
+ v.fill(-1)
+ v[max_d + 1] = 0 # k=1 starts at x=0
+
+ var trace := [] # Store V arrays for each d to backtrack later
+
+ # Find the edit distance
+ for d in range(0, max_d + 1):
+ # Store current V for backtracking
+ trace.append(v.duplicate())
+
+ for k in range(-d, d + 1, 2):
+ var k_offset := k + max_d
+
+ # Decide whether to move down or right
+ var x: int
+ if k == -d or (k != d and v[k_offset - 1] < v[k_offset + 1]):
+ x = v[k_offset + 1] # Move down (insert from b)
+ else:
+ x = v[k_offset - 1] + 1 # Move right (delete from a)
+
+ var y := x - k
+
+ # Follow diagonal as far as possible (matching characters)
+ while x < n and y < m and a[x] == b[y]:
+ x += 1
+ y += 1
+
+ v[k_offset] = x
+
+ # Check if we've reached the end
+ if x >= n and y >= m:
+ return _backtrack(a, b, trace, d, max_d)
+
+ # Should never reach here for valid inputs
+ return []
+
+
+# Backtrack through the edit graph to build the edit script
+static func _backtrack(a: PackedInt32Array, b: PackedInt32Array, trace: Array, d: int, max_d: int) -> Array[Edit]:
+ var edits: Array[Edit] = []
+ var x := a.size()
+ var y := b.size()
+
+ # Walk backwards through each d value
+ for depth in range(d, -1, -1):
+ var v: PackedInt32Array = trace[depth]
+ var k := x - y
+ var k_offset := k + max_d
+
+ # Determine previous k
+ var prev_k: int
+ if k == -depth or (k != depth and v[k_offset - 1] < v[k_offset + 1]):
+ prev_k = k + 1
+ else:
+ prev_k = k - 1
+
+ var prev_k_offset := prev_k + max_d
+ var prev_x := v[prev_k_offset]
+ var prev_y := prev_x - prev_k
+
+ # Extract diagonal (equal) characters
+ while x > prev_x and y > prev_y:
+ x -= 1
+ y -= 1
+ #var char_array := PackedInt32Array([a[x]])
+ edits.insert(0, Edit.new(Edit.Type.EQUAL, a[x]))
+
+ # Record the edit operation
+ if depth > 0:
+ if x == prev_x:
+ # Insert from b
+ y -= 1
+ #var char_array := PackedInt32Array([b[y]])
+ edits.insert(0, Edit.new(Edit.Type.INSERT, b[y]))
+ else:
+ # Delete from a
+ x -= 1
+ #var char_array := PackedInt32Array([a[x]])
+ edits.insert(0, Edit.new(Edit.Type.DELETE, a[x]))
+
+ return edits
+
+
+# Convert edit script to the DIV_ADD/DIV_SUB format
+static func _edits_to_diff_format(edits: Array[Edit]) -> Array[PackedInt32Array]:
+ var ldiff := PackedInt32Array()
+ var rdiff := PackedInt32Array()
+
+ for edit in edits:
+ match edit.type:
+ Edit.Type.EQUAL:
+ ldiff.append(edit.character)
+ rdiff.append(edit.character)
+ Edit.Type.INSERT:
+ ldiff.append(DIV_ADD)
+ ldiff.append(edit.character)
+ rdiff.append(DIV_SUB)
+ rdiff.append(edit.character)
+ Edit.Type.DELETE:
+ ldiff.append(DIV_SUB)
+ ldiff.append(edit.character)
+ rdiff.append(DIV_ADD)
+ rdiff.append(edit.character)
+
+ return [ldiff, rdiff]
+
+
+# prototype
+static func longestCommonSubsequence(text1 :String, text2 :String) -> PackedStringArray:
+ var text1Words := text1.split(" ")
+ var text2Words := text2.split(" ")
+ var text1WordCount := text1Words.size()
+ var text2WordCount := text2Words.size()
+ var solutionMatrix := Array()
+ for i in text1WordCount+1:
+ var ar := Array()
+ for n in text2WordCount+1:
+ ar.append(0)
+ solutionMatrix.append(ar)
+
+ for i in range(text1WordCount-1, 0, -1):
+ for j in range(text2WordCount-1, 0, -1):
+ if text1Words[i] == text2Words[j]:
+ solutionMatrix[i][j] = solutionMatrix[i + 1][j + 1] + 1;
+ else:
+ solutionMatrix[i][j] = max(solutionMatrix[i + 1][j], solutionMatrix[i][j + 1]);
+
+ var i := 0
+ var j := 0
+ var lcsResultList := PackedStringArray();
+ while (i < text1WordCount && j < text2WordCount):
+ if text1Words[i] == text2Words[j]:
+ @warning_ignore("return_value_discarded")
+ lcsResultList.append(text2Words[j])
+ i += 1
+ j += 1
+ else: if (solutionMatrix[i + 1][j] >= solutionMatrix[i][j + 1]):
+ i += 1
+ else:
+ j += 1
+ return lcsResultList
+
+
+static func markTextDifferences(text1 :String, text2 :String, lcsList :PackedStringArray, insertColor :Color, deleteColor:Color) -> String:
+ var stringBuffer := ""
+ if text1 == null and lcsList == null:
+ return stringBuffer
+
+ var text1Words := text1.split(" ")
+ var text2Words := text2.split(" ")
+ var i := 0
+ var j := 0
+ var word1LastIndex := 0
+ var word2LastIndex := 0
+ for k in lcsList.size():
+ while i < text1Words.size() and j < text2Words.size():
+ if text1Words[i] == lcsList[k] and text2Words[j] == lcsList[k]:
+ stringBuffer += "<SPAN>" + lcsList[k] + " </SPAN>"
+ word1LastIndex = i + 1
+ word2LastIndex = j + 1
+ i = text1Words.size()
+ j = text2Words.size()
+
+ else: if text1Words[i] != lcsList[k]:
+ while i < text1Words.size() and text1Words[i] != lcsList[k]:
+ stringBuffer += "<SPAN style='BACKGROUND-COLOR:" + deleteColor.to_html() + "'>" + text1Words[i] + " </SPAN>"
+ i += 1
+ else: if text2Words[j] != lcsList[k]:
+ while j < text2Words.size() and text2Words[j] != lcsList[k]:
+ stringBuffer += "<SPAN style='BACKGROUND-COLOR:" + insertColor.to_html() + "'>" + text2Words[j] + " </SPAN>"
+ j += 1
+ i = word1LastIndex
+ j = word2LastIndex
+
+ while word1LastIndex < text1Words.size():
+ stringBuffer += "<SPAN style='BACKGROUND-COLOR:" + deleteColor.to_html() + "'>" + text1Words[word1LastIndex] + " </SPAN>"
+ word1LastIndex += 1
+ while word2LastIndex < text2Words.size():
+ stringBuffer += "<SPAN style='BACKGROUND-COLOR:" + insertColor.to_html() + "'>" + text2Words[word2LastIndex] + " </SPAN>"
+ word2LastIndex += 1
+ return stringBuffer
diff --git a/addons/gdUnit4/src/core/GdDiffTool.gd.uid b/addons/gdUnit4/src/core/GdDiffTool.gd.uid
new file mode 100644
index 0000000..7b60098
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdDiffTool.gd.uid
@@ -0,0 +1 @@
+uid://ccj7h0qml5bng
diff --git a/addons/gdUnit4/src/core/GdObjects.gd b/addons/gdUnit4/src/core/GdObjects.gd
new file mode 100644
index 0000000..279e518
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdObjects.gd
@@ -0,0 +1,726 @@
+# This is a helper class to compare two objects by equals
+class_name GdObjects
+extends Resource
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+
+# introduced with Godot 4.3.beta1
+const TYPE_VOID = 1000
+const TYPE_VARARG = 1001
+const TYPE_VARIANT = 1002
+const TYPE_FUNC = 1003
+const TYPE_FUZZER = 1004
+# missing Godot types
+const TYPE_NODE = 2001
+const TYPE_CONTROL = 2002
+const TYPE_CANVAS = 2003
+const TYPE_ENUM = 2004
+
+
+const TYPE_AS_STRING_MAPPINGS := {
+ TYPE_NIL: "null",
+ TYPE_BOOL: "bool",
+ TYPE_INT: "int",
+ TYPE_FLOAT: "float",
+ TYPE_STRING: "String",
+ TYPE_VECTOR2: "Vector2",
+ TYPE_VECTOR2I: "Vector2i",
+ TYPE_RECT2: "Rect2",
+ TYPE_RECT2I: "Rect2i",
+ TYPE_VECTOR3: "Vector3",
+ TYPE_VECTOR3I: "Vector3i",
+ TYPE_TRANSFORM2D: "Transform2D",
+ TYPE_VECTOR4: "Vector4",
+ TYPE_VECTOR4I: "Vector4i",
+ TYPE_PLANE: "Plane",
+ TYPE_QUATERNION: "Quaternion",
+ TYPE_AABB: "AABB",
+ TYPE_BASIS: "Basis",
+ TYPE_TRANSFORM3D: "Transform3D",
+ TYPE_PROJECTION: "Projection",
+ TYPE_COLOR: "Color",
+ TYPE_STRING_NAME: "StringName",
+ TYPE_NODE_PATH: "NodePath",
+ TYPE_RID: "RID",
+ TYPE_OBJECT: "Object",
+ TYPE_CALLABLE: "Callable",
+ TYPE_SIGNAL: "Signal",
+ TYPE_DICTIONARY: "Dictionary",
+ TYPE_ARRAY: "Array",
+ TYPE_PACKED_BYTE_ARRAY: "PackedByteArray",
+ TYPE_PACKED_INT32_ARRAY: "PackedInt32Array",
+ TYPE_PACKED_INT64_ARRAY: "PackedInt64Array",
+ TYPE_PACKED_FLOAT32_ARRAY: "PackedFloat32Array",
+ TYPE_PACKED_FLOAT64_ARRAY: "PackedFloat64Array",
+ TYPE_PACKED_STRING_ARRAY: "PackedStringArray",
+ TYPE_PACKED_VECTOR2_ARRAY: "PackedVector2Array",
+ TYPE_PACKED_VECTOR3_ARRAY: "PackedVector3Array",
+ TYPE_PACKED_VECTOR4_ARRAY: "PackedVector4Array",
+ TYPE_PACKED_COLOR_ARRAY: "PackedColorArray",
+ TYPE_VOID: "void",
+ TYPE_VARARG: "VarArg",
+ TYPE_FUNC: "Func",
+ TYPE_FUZZER: "Fuzzer",
+ TYPE_VARIANT: "Variant"
+}
+
+
+class EditorNotifications:
+ # NOTE: Hardcoding to avoid runtime errors in exported projects when editor
+ # classes are not available. These values are unlikely to change.
+ # See: EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED
+ const NOTIFICATION_EDITOR_SETTINGS_CHANGED := 10000
+
+
+const NOTIFICATION_AS_STRING_MAPPINGS := {
+ TYPE_OBJECT: {
+ Object.NOTIFICATION_POSTINITIALIZE : "POSTINITIALIZE",
+ Object.NOTIFICATION_PREDELETE: "PREDELETE",
+ EditorNotifications.NOTIFICATION_EDITOR_SETTINGS_CHANGED: "EDITOR_SETTINGS_CHANGED",
+ },
+ TYPE_NODE: {
+ Node.NOTIFICATION_ENTER_TREE : "ENTER_TREE",
+ Node.NOTIFICATION_EXIT_TREE: "EXIT_TREE",
+ Node.NOTIFICATION_CHILD_ORDER_CHANGED: "CHILD_ORDER_CHANGED",
+ Node.NOTIFICATION_READY: "READY",
+ Node.NOTIFICATION_PAUSED: "PAUSED",
+ Node.NOTIFICATION_UNPAUSED: "UNPAUSED",
+ Node.NOTIFICATION_PHYSICS_PROCESS: "PHYSICS_PROCESS",
+ Node.NOTIFICATION_PROCESS: "PROCESS",
+ Node.NOTIFICATION_PARENTED: "PARENTED",
+ Node.NOTIFICATION_UNPARENTED: "UNPARENTED",
+ Node.NOTIFICATION_SCENE_INSTANTIATED: "INSTANCED",
+ Node.NOTIFICATION_DRAG_BEGIN: "DRAG_BEGIN",
+ Node.NOTIFICATION_DRAG_END: "DRAG_END",
+ Node.NOTIFICATION_PATH_RENAMED: "PATH_CHANGED",
+ Node.NOTIFICATION_INTERNAL_PROCESS: "INTERNAL_PROCESS",
+ Node.NOTIFICATION_INTERNAL_PHYSICS_PROCESS: "INTERNAL_PHYSICS_PROCESS",
+ Node.NOTIFICATION_POST_ENTER_TREE: "POST_ENTER_TREE",
+ Node.NOTIFICATION_WM_MOUSE_ENTER: "WM_MOUSE_ENTER",
+ Node.NOTIFICATION_WM_MOUSE_EXIT: "WM_MOUSE_EXIT",
+ Node.NOTIFICATION_APPLICATION_FOCUS_IN: "WM_FOCUS_IN",
+ Node.NOTIFICATION_APPLICATION_FOCUS_OUT: "WM_FOCUS_OUT",
+ #Node.NOTIFICATION_WM_QUIT_REQUEST: "WM_QUIT_REQUEST",
+ Node.NOTIFICATION_WM_GO_BACK_REQUEST: "WM_GO_BACK_REQUEST",
+ Node.NOTIFICATION_WM_WINDOW_FOCUS_OUT: "WM_UNFOCUS_REQUEST",
+ Node.NOTIFICATION_OS_MEMORY_WARNING: "OS_MEMORY_WARNING",
+ Node.NOTIFICATION_TRANSLATION_CHANGED: "TRANSLATION_CHANGED",
+ Node.NOTIFICATION_WM_ABOUT: "WM_ABOUT",
+ Node.NOTIFICATION_CRASH: "CRASH",
+ Node.NOTIFICATION_OS_IME_UPDATE: "OS_IME_UPDATE",
+ Node.NOTIFICATION_APPLICATION_RESUMED: "APP_RESUMED",
+ Node.NOTIFICATION_APPLICATION_PAUSED: "APP_PAUSED",
+ Node3D.NOTIFICATION_TRANSFORM_CHANGED: "TRANSFORM_CHANGED",
+ Node3D.NOTIFICATION_ENTER_WORLD: "ENTER_WORLD",
+ Node3D.NOTIFICATION_EXIT_WORLD: "EXIT_WORLD",
+ Node3D.NOTIFICATION_VISIBILITY_CHANGED: "VISIBILITY_CHANGED",
+ Skeleton3D.NOTIFICATION_UPDATE_SKELETON: "UPDATE_SKELETON",
+ CanvasItem.NOTIFICATION_DRAW: "DRAW",
+ CanvasItem.NOTIFICATION_VISIBILITY_CHANGED: "VISIBILITY_CHANGED",
+ CanvasItem.NOTIFICATION_ENTER_CANVAS: "ENTER_CANVAS",
+ CanvasItem.NOTIFICATION_EXIT_CANVAS: "EXIT_CANVAS",
+ #Popup.NOTIFICATION_POST_POPUP: "POST_POPUP",
+ #Popup.NOTIFICATION_POPUP_HIDE: "POPUP_HIDE",
+ },
+ TYPE_CONTROL : {
+ Object.NOTIFICATION_PREDELETE: "PREDELETE",
+ Container.NOTIFICATION_SORT_CHILDREN: "SORT_CHILDREN",
+ Control.NOTIFICATION_RESIZED: "RESIZED",
+ Control.NOTIFICATION_MOUSE_ENTER: "MOUSE_ENTER",
+ Control.NOTIFICATION_MOUSE_EXIT: "MOUSE_EXIT",
+ Control.NOTIFICATION_FOCUS_ENTER: "FOCUS_ENTER",
+ Control.NOTIFICATION_FOCUS_EXIT: "FOCUS_EXIT",
+ Control.NOTIFICATION_THEME_CHANGED: "THEME_CHANGED",
+ #Control.NOTIFICATION_MODAL_CLOSE: "MODAL_CLOSE",
+ Control.NOTIFICATION_SCROLL_BEGIN: "SCROLL_BEGIN",
+ Control.NOTIFICATION_SCROLL_END: "SCROLL_END",
+ }
+}
+
+
+enum COMPARE_MODE {
+ OBJECT_REFERENCE,
+ PARAMETER_DEEP_TEST
+}
+
+
+# prototype of better object to dictionary
+static func obj2dict(obj: Object, hashed_objects := Dictionary()) -> Dictionary:
+ if obj == null:
+ return {}
+ var clazz_name := obj.get_class()
+ var dict := Dictionary()
+ var clazz_path := ""
+
+ if is_instance_valid(obj) and obj.get_script() != null:
+ var script: Script = obj.get_script()
+ # handle build-in scripts
+ if script.resource_path != null and script.resource_path.contains(".tscn"):
+ var path_elements := script.resource_path.split(".tscn")
+ clazz_name = path_elements[0].get_file()
+ clazz_path = script.resource_path
+ else:
+ var d := inst_to_dict(obj)
+ clazz_path = d["@path"]
+ if d["@subpath"] != NodePath(""):
+ clazz_name = d["@subpath"]
+ dict["@inner_class"] = true
+ else:
+ clazz_name = clazz_path.get_file().replace(".gd", "")
+ dict["@path"] = clazz_path
+
+ for property in obj.get_property_list():
+ var property_name :String = property["name"]
+ var property_type :int = property["type"]
+ var property_value :Variant = obj.get(property_name)
+ if property_value is GDScript or property_value is Callable or property_value is RegEx:
+ continue
+ if (property["usage"] & PROPERTY_USAGE_SCRIPT_VARIABLE|PROPERTY_USAGE_DEFAULT
+ and not property["usage"] & PROPERTY_USAGE_CATEGORY
+ and not property["usage"] == 0):
+ if property_type == TYPE_OBJECT:
+ # prevent recursion
+ if hashed_objects.has(obj):
+ dict[property_name] = str(property_value)
+ continue
+ hashed_objects[obj] = true
+ @warning_ignore("unsafe_cast")
+ dict[property_name] = obj2dict(property_value as Object, hashed_objects)
+ else:
+ dict[property_name] = property_value
+ if obj is Node:
+ var childrens :Array = (obj as Node).get_children()
+ dict["childrens"] = childrens.map(func (child :Object) -> Dictionary: return obj2dict(child, hashed_objects))
+ if obj is TreeItem:
+ var childrens :Array = (obj as TreeItem).get_children()
+ dict["childrens"] = childrens.map(func (child :Object) -> Dictionary: return obj2dict(child, hashed_objects))
+
+ return {"%s" % clazz_name : dict}
+
+
+static func equals(obj_a :Variant, obj_b :Variant, case_sensitive :bool = false, compare_mode :COMPARE_MODE = COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool:
+ return _equals(obj_a, obj_b, case_sensitive, compare_mode, [], 0)
+
+
+static func equals_sorted(obj_a: Array[Variant], obj_b: Array[Variant], case_sensitive: bool = false, compare_mode: COMPARE_MODE = COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool:
+ var a: Array[Variant] = obj_a.duplicate()
+ var b: Array[Variant] = obj_b.duplicate()
+ a.sort()
+ b.sort()
+ return equals(a, b, case_sensitive, compare_mode)
+
+
+static func _equals(obj_a :Variant, obj_b :Variant, case_sensitive :bool, compare_mode :COMPARE_MODE, deep_stack :Array, stack_depth :int ) -> bool:
+ var type_a := typeof(obj_a)
+ var type_b := typeof(obj_b)
+ if stack_depth > 32:
+ prints("stack_depth", stack_depth, deep_stack)
+ push_error("GdUnit equals has max stack deep reached!")
+ return false
+
+ # use argument matcher if requested
+ if is_instance_valid(obj_a) and obj_a is GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_cast")
+ return (obj_a as GdUnitArgumentMatcher).is_match(obj_b)
+ if is_instance_valid(obj_b) and obj_b is GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_cast")
+ return (obj_b as GdUnitArgumentMatcher).is_match(obj_a)
+
+ stack_depth += 1
+ # fast fail is different types
+ if not _is_type_equivalent(type_a, type_b):
+ return false
+ # is same instance
+ if obj_a == obj_b:
+ return true
+ # handle null values
+ if obj_a == null and obj_b != null:
+ return false
+ if obj_b == null and obj_a != null:
+ return false
+
+ match type_a:
+ TYPE_OBJECT:
+ if deep_stack.has(obj_a) or deep_stack.has(obj_b):
+ return true
+ deep_stack.append(obj_a)
+ deep_stack.append(obj_b)
+ if compare_mode == COMPARE_MODE.PARAMETER_DEEP_TEST:
+ # fail fast
+ if not is_instance_valid(obj_a) or not is_instance_valid(obj_b):
+ return false
+ @warning_ignore("unsafe_method_access")
+ if obj_a.get_class() != obj_b.get_class():
+ return false
+ @warning_ignore("unsafe_cast")
+ var a := obj2dict(obj_a as Object)
+ @warning_ignore("unsafe_cast")
+ var b := obj2dict(obj_b as Object)
+ return _equals(a, b, case_sensitive, compare_mode, deep_stack, stack_depth)
+ return obj_a == obj_b
+
+ TYPE_ARRAY:
+ @warning_ignore("unsafe_method_access")
+ if obj_a.size() != obj_b.size():
+ return false
+ @warning_ignore("unsafe_method_access")
+ for index :int in obj_a.size():
+ if not _equals(obj_a[index], obj_b[index], case_sensitive, compare_mode, deep_stack, stack_depth):
+ return false
+ return true
+
+ TYPE_DICTIONARY:
+ @warning_ignore("unsafe_method_access")
+ if obj_a.size() != obj_b.size():
+ return false
+ @warning_ignore("unsafe_method_access")
+ for key :Variant in obj_a.keys():
+ @warning_ignore("unsafe_method_access")
+ var value_a :Variant = obj_a[key] if obj_a.has(key) else null
+ @warning_ignore("unsafe_method_access")
+ var value_b :Variant = obj_b[key] if obj_b.has(key) else null
+ if not _equals(value_a, value_b, case_sensitive, compare_mode, deep_stack, stack_depth):
+ return false
+ return true
+
+ TYPE_STRING:
+ if case_sensitive:
+ @warning_ignore("unsafe_method_access")
+ return obj_a.to_lower() == obj_b.to_lower()
+ else:
+ return obj_a == obj_b
+ return obj_a == obj_b
+
+
+@warning_ignore("shadowed_variable_base_class")
+static func notification_as_string(instance :Variant, notification :int) -> String:
+ var error := "Unknown notification: '%s' at instance: %s" % [notification, instance]
+ if instance is Node and NOTIFICATION_AS_STRING_MAPPINGS[TYPE_NODE].has(notification):
+ return NOTIFICATION_AS_STRING_MAPPINGS[TYPE_NODE].get(notification, error)
+ if instance is Control and NOTIFICATION_AS_STRING_MAPPINGS[TYPE_CONTROL].has(notification):
+ return NOTIFICATION_AS_STRING_MAPPINGS[TYPE_CONTROL].get(notification, error)
+ return NOTIFICATION_AS_STRING_MAPPINGS[TYPE_OBJECT].get(notification, error)
+
+
+static func string_to_type(value :String) -> int:
+ for type :int in TYPE_AS_STRING_MAPPINGS.keys():
+ if TYPE_AS_STRING_MAPPINGS.get(type) == value:
+ return type
+ return TYPE_NIL
+
+
+static func to_camel_case(value :String) -> String:
+ var p := to_pascal_case(value)
+ if not p.is_empty():
+ p[0] = p[0].to_lower()
+ return p
+
+
+static func to_pascal_case(value :String) -> String:
+ return value.capitalize().replace(" ", "")
+
+
+@warning_ignore("return_value_discarded")
+static func to_snake_case(value :String) -> String:
+ var result := PackedStringArray()
+ for ch in value:
+ var lower_ch := ch.to_lower()
+ if ch != lower_ch and result.size() > 1:
+ result.append('_')
+ result.append(lower_ch)
+ return ''.join(result)
+
+
+static func is_snake_case(value :String) -> bool:
+ for ch in value:
+ if ch == '_':
+ continue
+ if ch == ch.to_upper():
+ return false
+ return true
+
+
+static func type_as_string(type :int) -> String:
+ if type < TYPE_MAX:
+ return type_string(type)
+ return TYPE_AS_STRING_MAPPINGS.get(type, "Variant")
+
+
+static func typeof_as_string(value :Variant) -> String:
+ return TYPE_AS_STRING_MAPPINGS.get(typeof(value), "Unknown type")
+
+
+static func all_types() -> PackedInt32Array:
+ return PackedInt32Array(TYPE_AS_STRING_MAPPINGS.keys())
+
+
+static func string_as_typeof(type_name :String) -> int:
+ var type :Variant = TYPE_AS_STRING_MAPPINGS.find_key(type_name)
+ return type if type != null else TYPE_VARIANT
+
+
+static func is_primitive_type(value :Variant) -> bool:
+ return typeof(value) in [TYPE_BOOL, TYPE_STRING, TYPE_STRING_NAME, TYPE_INT, TYPE_FLOAT]
+
+
+static func _is_type_equivalent(type_a :int, type_b :int) -> bool:
+ # don't test for TYPE_STRING_NAME equivalenz
+ if type_a == TYPE_STRING_NAME or type_b == TYPE_STRING_NAME:
+ return true
+ if GdUnitSettings.is_strict_number_type_compare():
+ return type_a == type_b
+ return (
+ (type_a == TYPE_FLOAT and type_b == TYPE_INT)
+ or (type_a == TYPE_INT and type_b == TYPE_FLOAT)
+ or type_a == type_b)
+
+
+static func is_engine_type(value :Variant) -> bool:
+ if value is GDScript or value is ScriptExtension:
+ return false
+ var obj: Object = value
+ if is_instance_valid(obj) and obj.has_method("is_class"):
+ return obj.is_class("GDScriptNativeClass")
+ return false
+
+
+static func is_type(value :Variant) -> bool:
+ # is an build-in type
+ if typeof(value) != TYPE_OBJECT:
+ return false
+ # is a engine class type
+ if is_engine_type(value):
+ return true
+ # is a custom class type
+ @warning_ignore("unsafe_cast")
+ if value is GDScript and (value as GDScript).can_instantiate():
+ return true
+ return false
+
+
+static func _is_same(left :Variant, right :Variant) -> bool:
+ var left_type := -1 if left == null else typeof(left)
+ var right_type := -1 if right == null else typeof(right)
+
+ # if typ different can't be the same
+ if left_type != right_type:
+ return false
+ if left_type == TYPE_OBJECT and right_type == TYPE_OBJECT:
+ @warning_ignore("unsafe_cast")
+ return (left as Object).get_instance_id() == (right as Object).get_instance_id()
+ return equals(left, right)
+
+
+static func is_object(value :Variant) -> bool:
+ return typeof(value) == TYPE_OBJECT
+
+
+static func is_script(value :Variant) -> bool:
+ return is_object(value) and value is Script
+
+
+static func is_native_class(value :Variant) -> bool:
+ return is_object(value) and is_engine_type(value)
+
+
+static func is_scene(value :Variant) -> bool:
+ return is_object(value) and value is PackedScene
+
+
+static func is_scene_resource_path(value :Variant) -> bool:
+ @warning_ignore("unsafe_cast")
+ return value is String and (value as String).ends_with(".tscn")
+
+
+static func is_singleton(value: Variant) -> bool:
+ if not is_instance_valid(value) or is_native_class(value):
+ return false
+ for name in Engine.get_singleton_list():
+ @warning_ignore("unsafe_cast")
+ if (value as Object).is_class(name):
+ return true
+ return false
+
+
+static func is_instance(value :Variant) -> bool:
+ if not is_instance_valid(value) or is_native_class(value):
+ return false
+ @warning_ignore("unsafe_cast")
+ if is_script(value) and (value as Script).get_instance_base_type() == "":
+ return true
+ if is_scene(value):
+ return true
+ @warning_ignore("unsafe_cast")
+ return not (value as Object).has_method('new') and not (value as Object).has_method('instance')
+
+
+# only object form type Node and attached filename
+static func is_instance_scene(instance :Variant) -> bool:
+ if instance is Node:
+ var node: Node = instance
+ return node.get_scene_file_path() != null and not node.get_scene_file_path().is_empty()
+ return false
+
+
+static func can_be_instantiate(obj :Variant) -> bool:
+ if not obj or is_engine_type(obj):
+ return false
+ @warning_ignore("unsafe_cast")
+ return (obj as Object).has_method("new")
+
+
+static func create_instance(clazz :Variant) -> GdUnitResult:
+ match typeof(clazz):
+ TYPE_OBJECT:
+ # test is given clazz already an instance
+ if is_instance(clazz):
+ return GdUnitResult.success(clazz)
+ @warning_ignore("unsafe_method_access")
+ return GdUnitResult.success(clazz.new())
+ TYPE_STRING:
+ var clazz_name: String = clazz
+ if ClassDB.class_exists(clazz_name):
+ if Engine.has_singleton(clazz_name):
+ return GdUnitResult.error("Not allowed to create a instance for singelton '%s'." % clazz_name)
+ if not ClassDB.can_instantiate(clazz_name):
+ return GdUnitResult.error("Can't instance Engine class '%s'." % clazz_name)
+ return GdUnitResult.success(ClassDB.instantiate(clazz_name))
+ else:
+ var clazz_path :String = extract_class_path(clazz_name)[0]
+ if not FileAccess.file_exists(clazz_path):
+ return GdUnitResult.error("Class '%s' not found." % clazz_name)
+ var script: GDScript = load(clazz_path)
+ if script != null:
+ return GdUnitResult.success(script.new())
+ else:
+ return GdUnitResult.error("Can't create instance for '%s'." % clazz_name)
+ return GdUnitResult.error("Can't create instance for class '%s'." % str(clazz))
+
+
+## We do dispose 'GDScriptFunctionState' in a kacky style because the class is not visible anymore
+static func dispose_function_state(func_state: Variant) -> void:
+ if func_state != null and str(func_state).contains("GDScriptFunctionState"):
+ @warning_ignore("unsafe_method_access")
+ func_state.completed.emit()
+
+
+@warning_ignore("return_value_discarded")
+static func extract_class_path(clazz :Variant) -> PackedStringArray:
+ var clazz_path := PackedStringArray()
+ if clazz is String:
+ @warning_ignore("unsafe_cast")
+ clazz_path.append(clazz as String)
+ return clazz_path
+ if is_instance(clazz):
+ # is instance a script instance?
+ var script: GDScript = clazz.script
+ if script != null:
+ return extract_class_path(script)
+ return clazz_path
+
+ if clazz is GDScript:
+ var script: GDScript = clazz
+ if not script.resource_path.is_empty():
+ clazz_path.append(script.resource_path)
+ return clazz_path
+ # if not found we go the expensive way and extract the path form the script by creating an instance
+ var arg_list := build_function_default_arguments(script, "_init")
+ var instance: Object = script.callv("new", arg_list)
+ var clazz_info := inst_to_dict(instance)
+ GdUnitTools.free_instance(instance)
+ @warning_ignore("unsafe_cast")
+ clazz_path.append(clazz_info["@path"] as String)
+ if clazz_info.has("@subpath"):
+ var sub_path :String = clazz_info["@subpath"]
+ if not sub_path.is_empty():
+ var sub_paths := sub_path.split("/")
+ clazz_path += sub_paths
+ return clazz_path
+ return clazz_path
+
+
+static func extract_class_name_from_class_path(clazz_path :PackedStringArray) -> String:
+ var base_clazz := clazz_path[0]
+ # return original class name if engine class
+ if ClassDB.class_exists(base_clazz):
+ return base_clazz
+ var clazz_name := to_pascal_case(base_clazz.get_basename().get_file())
+ for path_index in range(1, clazz_path.size()):
+ clazz_name += "." + clazz_path[path_index]
+ return clazz_name
+
+
+static func extract_class_name(clazz :Variant) -> GdUnitResult:
+ if clazz == null:
+ return GdUnitResult.error("Can't extract class name form a null value.")
+
+ if is_instance(clazz):
+ # is instance a script instance?
+ var script: GDScript = clazz.script
+ if script != null:
+ return extract_class_name(script)
+ @warning_ignore("unsafe_cast")
+ return GdUnitResult.success((clazz as Object).get_class())
+
+ # extract name form full qualified class path
+ if clazz is String:
+ var clazz_name: String = clazz
+ if ClassDB.class_exists(clazz_name):
+ return GdUnitResult.success(clazz_name)
+ var source_script :GDScript = load(clazz_name)
+ clazz_name = GdScriptParser.new().get_class_name(source_script)
+ return GdUnitResult.success(to_pascal_case(clazz_name))
+
+ if is_primitive_type(clazz):
+ return GdUnitResult.error("Can't extract class name for an primitive '%s'" % type_as_string(typeof(clazz)))
+
+ if is_script(clazz):
+ @warning_ignore("unsafe_cast")
+ if (clazz as Script).resource_path.is_empty():
+ var class_path := extract_class_name_from_class_path(extract_class_path(clazz))
+ return GdUnitResult.success(class_path);
+ return extract_class_name(clazz.resource_path)
+
+ # need to create an instance for a class typ the extract the class name
+ @warning_ignore("unsafe_method_access")
+ var instance :Variant = clazz.new()
+ if instance == null:
+ return GdUnitResult.error("Can't create a instance for class '%s'" % str(clazz))
+ var result := extract_class_name(instance)
+ @warning_ignore("return_value_discarded")
+ GdUnitTools.free_instance(instance)
+ return result
+
+
+static func extract_inner_clazz_names(clazz_name :String, script_path :PackedStringArray) -> PackedStringArray:
+ var inner_classes := PackedStringArray()
+
+ if ClassDB.class_exists(clazz_name):
+ return inner_classes
+ var script :GDScript = load(script_path[0])
+ var map := script.get_script_constant_map()
+ for key :String in map.keys():
+ var value :Variant = map.get(key)
+ if value is GDScript:
+ var class_path := extract_class_path(value)
+ @warning_ignore("return_value_discarded")
+ inner_classes.append(class_path[1])
+ return inner_classes
+
+
+static func extract_class_functions(clazz_name :String, script_path :PackedStringArray) -> Array:
+ if ClassDB.class_get_method_list(clazz_name):
+ return ClassDB.class_get_method_list(clazz_name)
+
+ if not FileAccess.file_exists(script_path[0]):
+ return Array()
+ var script :GDScript = load(script_path[0])
+ if script is GDScript:
+ # if inner class on class path we have to load the script from the script_constant_map
+ if script_path.size() == 2 and script_path[1] != "":
+ var inner_classes := script_path[1]
+ var map := script.get_script_constant_map()
+ script = map[inner_classes]
+ var clazz_functions :Array = script.get_method_list()
+ var base_clazz :String = script.get_instance_base_type()
+ if base_clazz:
+ return extract_class_functions(base_clazz, script_path)
+ return clazz_functions
+ return Array()
+
+
+# scans all registert script classes for given <clazz_name>
+# if the class is public in the global space than return true otherwise false
+# public class means the script class is defined by 'class_name <name>'
+static func is_public_script_class(clazz_name :String) -> bool:
+ var script_classes:Array[Dictionary] = ProjectSettings.get_global_class_list()
+ for class_info in script_classes:
+ if class_info.has("class"):
+ if class_info["class"] == clazz_name:
+ return true
+ return false
+
+
+static func build_function_default_arguments(script :GDScript, func_name :String) -> Array:
+ var arg_list := Array()
+ for func_sig in script.get_script_method_list():
+ if func_sig["name"] == func_name:
+ var args :Array[Dictionary] = func_sig["args"]
+ for arg in args:
+ var value_type :int = arg["type"]
+ var default_value :Variant = default_value_by_type(value_type)
+ arg_list.append(default_value)
+ return arg_list
+ return arg_list
+
+
+static func default_value_by_type(type :int) -> Variant:
+ assert(type < TYPE_MAX)
+ assert(type >= 0)
+
+ match type:
+ TYPE_NIL: return null
+ TYPE_BOOL: return false
+ TYPE_INT: return 0
+ TYPE_FLOAT: return 0.0
+ TYPE_STRING: return ""
+ TYPE_VECTOR2: return Vector2.ZERO
+ TYPE_VECTOR2I: return Vector2i.ZERO
+ TYPE_VECTOR3: return Vector3.ZERO
+ TYPE_VECTOR3I: return Vector3i.ZERO
+ TYPE_VECTOR4: return Vector4.ZERO
+ TYPE_VECTOR4I: return Vector4i.ZERO
+ TYPE_RECT2: return Rect2()
+ TYPE_RECT2I: return Rect2i()
+ TYPE_TRANSFORM2D: return Transform2D()
+ TYPE_PLANE: return Plane()
+ TYPE_QUATERNION: return Quaternion()
+ TYPE_AABB: return AABB()
+ TYPE_BASIS: return Basis()
+ TYPE_TRANSFORM3D: return Transform3D()
+ TYPE_COLOR: return Color()
+ TYPE_NODE_PATH: return NodePath()
+ TYPE_RID: return RID()
+ TYPE_OBJECT: return null
+ TYPE_CALLABLE: return Callable()
+ TYPE_ARRAY: return []
+ TYPE_DICTIONARY: return {}
+ TYPE_PACKED_BYTE_ARRAY: return PackedByteArray()
+ TYPE_PACKED_COLOR_ARRAY: return PackedColorArray()
+ TYPE_PACKED_INT32_ARRAY: return PackedInt32Array()
+ TYPE_PACKED_INT64_ARRAY: return PackedInt64Array()
+ TYPE_PACKED_FLOAT32_ARRAY: return PackedFloat32Array()
+ TYPE_PACKED_FLOAT64_ARRAY: return PackedFloat64Array()
+ TYPE_PACKED_STRING_ARRAY: return PackedStringArray()
+ TYPE_PACKED_VECTOR2_ARRAY: return PackedVector2Array()
+ TYPE_PACKED_VECTOR3_ARRAY: return PackedVector3Array()
+
+ push_error("Can't determine a default value for type: '%s', Please create a Bug issue and attach the stacktrace please." % type)
+ return null
+
+
+static func find_nodes_by_class(root: Node, cls: String, recursive: bool = false) -> Array[Node]:
+ if not recursive:
+ return _find_nodes_by_class_no_rec(root, cls)
+ return _find_nodes_by_class(root, cls)
+
+
+static func _find_nodes_by_class_no_rec(parent: Node, cls: String) -> Array[Node]:
+ var result :Array[Node] = []
+ for ch in parent.get_children():
+ if ch.get_class() == cls:
+ result.append(ch)
+ return result
+
+
+static func _find_nodes_by_class(root: Node, cls: String) -> Array[Node]:
+ var result :Array[Node] = []
+ var stack :Array[Node] = [root]
+ while stack:
+ var node :Node = stack.pop_back()
+ if node.get_class() == cls:
+ result.append(node)
+ for ch in node.get_children():
+ stack.push_back(ch)
+ return result
diff --git a/addons/gdUnit4/src/core/GdObjects.gd.uid b/addons/gdUnit4/src/core/GdObjects.gd.uid
new file mode 100644
index 0000000..dc1034b
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdObjects.gd.uid
@@ -0,0 +1 @@
+uid://cfk1e01rfphf0
diff --git a/addons/gdUnit4/src/core/GdUnit4Version.gd b/addons/gdUnit4/src/core/GdUnit4Version.gd
new file mode 100644
index 0000000..3e6a334
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnit4Version.gd
@@ -0,0 +1,65 @@
+class_name GdUnit4Version
+extends RefCounted
+
+const VERSION_PATTERN = "[center][color=#9887c4]gd[/color][color=#7a57d6]Unit[/color][color=#9887c4]4[/color] [color=#9887c4]${version}[/color][/center]"
+
+var _major :int
+var _minor :int
+var _patch :int
+
+
+func _init(major :int, minor :int, patch :int) -> void:
+ _major = major
+ _minor = minor
+ _patch = patch
+
+
+static func parse(value :String) -> GdUnit4Version:
+ var regex := RegEx.new()
+ @warning_ignore("return_value_discarded")
+ regex.compile("[a-zA-Z:,-]+")
+ var cleaned := regex.sub(value, "", true)
+ var parts := cleaned.split(".")
+ var major := parts[0].to_int()
+ var minor := parts[1].to_int()
+ var patch := parts[2].to_int() if parts.size() > 2 else 0
+ return GdUnit4Version.new(major, minor, patch)
+
+
+static func current() -> GdUnit4Version:
+ var config := ConfigFile.new()
+ @warning_ignore("return_value_discarded")
+ config.load('addons/gdUnit4/plugin.cfg')
+ @warning_ignore("unsafe_cast")
+ return parse(config.get_value('plugin', 'version') as String)
+
+
+func equals(other :GdUnit4Version) -> bool:
+ return _major == other._major and _minor == other._minor and _patch == other._patch
+
+
+func is_greater(other :GdUnit4Version) -> bool:
+ if _major > other._major:
+ return true
+ if _major == other._major and _minor > other._minor:
+ return true
+ return _major == other._major and _minor == other._minor and _patch > other._patch
+
+
+static func init_version_label(label :Control) -> void:
+ var config := ConfigFile.new()
+ @warning_ignore("return_value_discarded")
+ config.load('addons/gdUnit4/plugin.cfg')
+ var version :String = config.get_value('plugin', 'version')
+ if label is RichTextLabel:
+ (label as RichTextLabel).text = VERSION_PATTERN.replace('${version}', version)
+ else:
+ (label as Label).text = "gdUnit4 " + version
+
+
+func _to_string() -> String:
+ return "v%d.%d.%d" % [_major, _minor, _patch]
+
+
+func documentation_version() -> String:
+ return "v%d.%d.x" % [_major, _minor]
diff --git a/addons/gdUnit4/src/core/GdUnit4Version.gd.uid b/addons/gdUnit4/src/core/GdUnit4Version.gd.uid
new file mode 100644
index 0000000..22beb73
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnit4Version.gd.uid
@@ -0,0 +1 @@
+uid://dm2llfcfmgmox
diff --git a/addons/gdUnit4/src/core/GdUnitFileAccess.gd b/addons/gdUnit4/src/core/GdUnitFileAccess.gd
new file mode 100644
index 0000000..f4ba003
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitFileAccess.gd
@@ -0,0 +1,232 @@
+class_name GdUnitFileAccess
+extends RefCounted
+
+const GDUNIT_TEMP := "user://tmp"
+
+
+static func current_dir() -> String:
+ return ProjectSettings.globalize_path("res://")
+
+
+static func clear_tmp() -> void:
+ delete_directory(GDUNIT_TEMP)
+
+
+# Creates a new file under
+static func create_temp_file(relative_path :String, file_name :String, mode := FileAccess.WRITE) -> FileAccess:
+ var file_path := create_temp_dir(relative_path) + "/" + file_name
+ var file := FileAccess.open(file_path, mode)
+ if file == null:
+ push_error("Error creating temporary file at: %s, %s" % [file_path, error_string(FileAccess.get_open_error())])
+ return file
+
+
+static func temp_dir() -> String:
+ if not DirAccess.dir_exists_absolute(GDUNIT_TEMP):
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(GDUNIT_TEMP)
+ return GDUNIT_TEMP
+
+
+static func create_temp_dir(folder_name :String) -> String:
+ var new_folder := temp_dir() + "/" + folder_name
+ if not DirAccess.dir_exists_absolute(new_folder):
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(new_folder)
+ return new_folder
+
+
+static func copy_file(from_file :String, to_dir :String) -> GdUnitResult:
+ var dir := DirAccess.open(to_dir)
+ if dir != null:
+ var to_file := to_dir + "/" + from_file.get_file()
+ prints("Copy %s to %s" % [from_file, to_file])
+ var error := dir.copy(from_file, to_file)
+ if error != OK:
+ return GdUnitResult.error("Can't copy file form '%s' to '%s'. Error: '%s'" % [from_file, to_file, error_string(error)])
+ return GdUnitResult.success(to_file)
+ return GdUnitResult.error("Directory not found: " + to_dir)
+
+
+static func copy_directory(from_dir :String, to_dir :String, recursive :bool = false) -> bool:
+ if not DirAccess.dir_exists_absolute(from_dir):
+ push_error("Source directory not found '%s'" % from_dir)
+ return false
+
+ # check if destination exists
+ if not DirAccess.dir_exists_absolute(to_dir):
+ # create it
+ var err := DirAccess.make_dir_recursive_absolute(to_dir)
+ if err != OK:
+ push_error("Can't create directory '%s'. Error: %s" % [to_dir, error_string(err)])
+ return false
+ var source_dir := DirAccess.open(from_dir)
+ var dest_dir := DirAccess.open(to_dir)
+ if source_dir != null:
+ @warning_ignore("return_value_discarded")
+ source_dir.list_dir_begin()
+ var next := "."
+
+ while next != "":
+ next = source_dir.get_next()
+ if next == "" or next == "." or next == "..":
+ continue
+ var source := source_dir.get_current_dir() + "/" + next
+ var dest := dest_dir.get_current_dir() + "/" + next
+ if source_dir.current_is_dir():
+ if recursive:
+ @warning_ignore("return_value_discarded")
+ copy_directory(source + "/", dest, recursive)
+ continue
+ var err := source_dir.copy(source, dest)
+ if err != OK:
+ push_error("Error checked copy file '%s' to '%s'" % [source, dest])
+ return false
+
+ return true
+ else:
+ push_error("Directory not found: " + from_dir)
+ return false
+
+
+static func delete_directory(path :String, only_content := false) -> void:
+ var dir := DirAccess.open(path)
+ if dir != null:
+ dir.include_hidden = true
+ @warning_ignore("return_value_discarded")
+ dir.list_dir_begin()
+ var file_name := "."
+ while file_name != "":
+ file_name = dir.get_next()
+ if file_name.is_empty() or file_name == "." or file_name == "..":
+ continue
+ var next := path + "/" +file_name
+ if dir.current_is_dir():
+ delete_directory(next)
+ else:
+ # delete file
+ var err := dir.remove(next)
+ if err:
+ push_error("Delete %s failed: %s" % [next, error_string(err)])
+ if not only_content:
+ var err := dir.remove(path)
+ if err:
+ push_error("Delete %s failed: %s" % [path, error_string(err)])
+
+
+static func delete_path_index_lower_equals_than(path :String, prefix :String, index :int) -> int:
+ var dir := DirAccess.open(path)
+ if dir == null:
+ return 0
+ var deleted := 0
+ @warning_ignore("return_value_discarded")
+ dir.list_dir_begin()
+ var next := "."
+ while next != "":
+ next = dir.get_next()
+ if next.is_empty() or next == "." or next == "..":
+ continue
+ if next.begins_with(prefix):
+ var current_index := next.split("_")[1].to_int()
+ if current_index <= index:
+ deleted += 1
+ delete_directory(path + "/" + next)
+ return deleted
+
+
+# scans given path for sub directories by given prefix and returns the highest index numer
+# e.g. <prefix_%d>
+static func find_last_path_index(path :String, prefix :String) -> int:
+ var dir := DirAccess.open(path)
+ if dir == null:
+ return 0
+ var last_iteration := 0
+ @warning_ignore("return_value_discarded")
+ dir.list_dir_begin()
+ var next := "."
+ while next != "":
+ next = dir.get_next()
+ if next.is_empty() or next == "." or next == "..":
+ continue
+ if next.begins_with(prefix):
+ var iteration := next.split("_")[1].to_int()
+ if iteration > last_iteration:
+ last_iteration = iteration
+ return last_iteration
+
+
+static func as_resource_path(value: String) -> String:
+ if value.begins_with("res://"):
+ return value
+ return "res://" + value.trim_prefix("//").trim_prefix("/").trim_suffix("/")
+
+
+static func scan_dir(path :String) -> PackedStringArray:
+ var dir := DirAccess.open(path)
+ if dir == null or not dir.dir_exists(path):
+ return PackedStringArray()
+ var content := PackedStringArray()
+ dir.include_hidden = true
+ @warning_ignore("return_value_discarded")
+ dir.list_dir_begin()
+ var next := "."
+ while next != "":
+ next = dir.get_next()
+ if next.is_empty() or next == "." or next == "..":
+ continue
+ @warning_ignore("return_value_discarded")
+ content.append(next)
+ return content
+
+
+static func resource_as_array(resource_path :String) -> PackedStringArray:
+ var file := FileAccess.open(resource_path, FileAccess.READ)
+ if file == null:
+ push_error("ERROR: Can't read resource '%s'. %s" % [resource_path, error_string(FileAccess.get_open_error())])
+ return PackedStringArray()
+ var file_content := PackedStringArray()
+ while not file.eof_reached():
+ @warning_ignore("return_value_discarded")
+ file_content.append(file.get_line())
+ return file_content
+
+
+static func resource_as_string(resource_path :String) -> String:
+ var file := FileAccess.open(resource_path, FileAccess.READ)
+ if file == null:
+ push_error("ERROR: Can't read resource '%s'. %s" % [resource_path, error_string(FileAccess.get_open_error())])
+ return ""
+ return file.get_as_text()
+
+
+static func make_qualified_path(path :String) -> String:
+ if path.begins_with("res://"):
+ return path
+ if path.begins_with("//"):
+ return path.replace("//", "res://")
+ if path.begins_with("/"):
+ return "res:/" + path
+ return path
+
+
+static func extract_zip(zip_package :String, dest_path :String) -> GdUnitResult:
+ var zip: ZIPReader = ZIPReader.new()
+ var err := zip.open(zip_package)
+ if err != OK:
+ return GdUnitResult.error("Extracting `%s` failed! Please collect the error log and report this. Error Code: %s" % [zip_package, err])
+ var zip_entries: PackedStringArray = zip.get_files()
+ # Get base path and step over archive folder
+ var archive_path := zip_entries[0]
+ zip_entries.remove_at(0)
+
+ for zip_entry in zip_entries:
+ var new_file_path: String = dest_path + "/" + zip_entry.replace(archive_path, "")
+ if zip_entry.ends_with("/"):
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(new_file_path)
+ continue
+ var file: FileAccess = FileAccess.open(new_file_path, FileAccess.WRITE)
+ file.store_buffer(zip.read_file(zip_entry))
+ @warning_ignore("return_value_discarded")
+ zip.close()
+ return GdUnitResult.success(dest_path)
diff --git a/addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid b/addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid
new file mode 100644
index 0000000..91c595c
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitFileAccess.gd.uid
@@ -0,0 +1 @@
+uid://cqbvlw7jnsrm8
diff --git a/addons/gdUnit4/src/core/GdUnitProperty.gd b/addons/gdUnit4/src/core/GdUnitProperty.gd
new file mode 100644
index 0000000..3d050b1
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitProperty.gd
@@ -0,0 +1,81 @@
+class_name GdUnitProperty
+extends RefCounted
+
+
+var _name :String
+var _help :String
+var _type :int
+var _value :Variant
+var _value_set :PackedStringArray
+var _default :Variant
+
+
+func _init(p_name :String, p_type :int, p_value :Variant, p_default_value :Variant, p_help :="", p_value_set := PackedStringArray()) -> void:
+ _name = p_name
+ _type = p_type
+ _value = p_value
+ _value_set = p_value_set
+ _default = p_default_value
+ _help = p_help
+
+
+func name() -> String:
+ return _name
+
+
+func type() -> int:
+ return _type
+
+
+func value() -> Variant:
+ return _value
+
+
+func int_value() -> int:
+ return _value
+
+func value_as_string() -> String:
+ return _value
+
+
+func value_set() -> PackedStringArray:
+ return _value_set
+
+
+func is_selectable_value() -> bool:
+ return not _value_set.is_empty()
+
+
+func set_value(p_value: Variant) -> void:
+ match _type:
+ TYPE_STRING:
+ _value = str(p_value)
+ TYPE_BOOL:
+ _value = type_convert(p_value, TYPE_BOOL)
+ TYPE_INT:
+ _value = type_convert(p_value, TYPE_INT)
+ TYPE_FLOAT:
+ _value = type_convert(p_value, TYPE_FLOAT)
+ TYPE_DICTIONARY:
+ _value = type_convert(p_value, TYPE_DICTIONARY)
+ _:
+ _value = p_value
+
+
+func default() -> Variant:
+ return _default
+
+
+func category() -> String:
+ var elements := _name.split("/")
+ if elements.size() > 3:
+ return elements[2]
+ return ""
+
+
+func help() -> String:
+ return _help
+
+
+func _to_string() -> String:
+ return "%-64s %-10s %-10s (%s) help:%s set:%s" % [name(), type(), value(), default(), help(), _value_set]
diff --git a/addons/gdUnit4/src/core/GdUnitProperty.gd.uid b/addons/gdUnit4/src/core/GdUnitProperty.gd.uid
new file mode 100644
index 0000000..e1220b6
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitProperty.gd.uid
@@ -0,0 +1 @@
+uid://bcfqsjp4j23io
diff --git a/addons/gdUnit4/src/core/GdUnitResult.gd b/addons/gdUnit4/src/core/GdUnitResult.gd
new file mode 100644
index 0000000..42392a5
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitResult.gd
@@ -0,0 +1,109 @@
+class_name GdUnitResult
+extends RefCounted
+
+enum {
+ SUCCESS,
+ WARN,
+ ERROR,
+ EMPTY
+}
+
+var _state: int
+var _warn_message := ""
+var _error_message := ""
+var _value :Variant = null
+
+
+static func empty() -> GdUnitResult:
+ var result := GdUnitResult.new()
+ result._state = EMPTY
+ return result
+
+
+static func success(p_value: Variant = "") -> GdUnitResult:
+ assert(p_value != null, "The value must not be NULL")
+ var result := GdUnitResult.new()
+ result._value = p_value
+ result._state = SUCCESS
+ return result
+
+
+static func warn(p_warn_message: String, p_value: Variant = null) -> GdUnitResult:
+ assert(not p_warn_message.is_empty()) #,"The message must not be empty")
+ var result := GdUnitResult.new()
+ result._value = p_value
+ result._warn_message = p_warn_message
+ result._state = WARN
+ return result
+
+
+static func error(p_error_message: String) -> GdUnitResult:
+ assert(not p_error_message.is_empty(), "The message must not be empty")
+ var result := GdUnitResult.new()
+ result._value = null
+ result._error_message = p_error_message
+ result._state = ERROR
+ return result
+
+
+func is_success() -> bool:
+ return _state == SUCCESS
+
+
+func is_warn() -> bool:
+ return _state == WARN
+
+
+func is_error() -> bool:
+ return _state == ERROR
+
+
+func is_empty() -> bool:
+ return _state == EMPTY
+
+
+func value() -> Variant:
+ return _value
+
+
+func value_as_string() -> String:
+ return _value
+
+
+func or_else(p_value: Variant) -> Variant:
+ if not is_success():
+ return p_value
+ return value()
+
+
+func error_message() -> String:
+ return _error_message
+
+
+func warn_message() -> String:
+ return _warn_message
+
+
+func _to_string() -> String:
+ return str(GdUnitResult.serialize(self))
+
+
+static func serialize(result: GdUnitResult) -> Dictionary:
+ if result == null:
+ push_error("Can't serialize a Null object from type GdUnitResult")
+ return {
+ "state" : result._state,
+ "value" : var_to_str(result._value),
+ "warn_msg" : result._warn_message,
+ "err_msg" : result._error_message
+ }
+
+
+static func deserialize(config: Dictionary) -> GdUnitResult:
+ var result := GdUnitResult.new()
+ var cfg_value: String = config.get("value", "")
+ result._value = str_to_var(cfg_value)
+ result._warn_message = config.get("warn_msg", null)
+ result._error_message = config.get("err_msg", null)
+ result._state = config.get("state")
+ return result
diff --git a/addons/gdUnit4/src/core/GdUnitResult.gd.uid b/addons/gdUnit4/src/core/GdUnitResult.gd.uid
new file mode 100644
index 0000000..52ea4fb
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitResult.gd.uid
@@ -0,0 +1 @@
+uid://v42xavdbquis
diff --git a/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd
new file mode 100644
index 0000000..0d1a42d
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd
@@ -0,0 +1,139 @@
+class_name GdUnitRunnerConfig
+extends Resource
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+const CONFIG_VERSION = "5.0"
+const VERSION = "version"
+const TESTS = "tests"
+const SERVER_PORT = "server_port"
+const EXIT_FAIL_FAST = "exit_on_first_fail"
+
+const CONFIG_FILE = "res://addons/gdUnit4/GdUnitRunner.cfg"
+
+var _config := {
+ VERSION : CONFIG_VERSION,
+ # a set of directories or testsuite paths as key and a optional set of testcases as values
+
+ TESTS : Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase),
+
+ # the port of running test server for this session
+ SERVER_PORT : -1,
+
+ # Exit on first failure
+ EXIT_FAIL_FAST : false
+ }
+
+
+func version() -> String:
+ return _config[VERSION]
+
+
+func clear() -> GdUnitRunnerConfig:
+ _config[TESTS] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)
+ return self
+
+
+func set_server_port(port: int) -> GdUnitRunnerConfig:
+ _config[SERVER_PORT] = port
+ return self
+
+
+func server_port() -> int:
+ return _config.get(SERVER_PORT, -1)
+
+
+func do_fail_fast(fail_fast: bool) -> GdUnitRunnerConfig:
+ _config[EXIT_FAIL_FAST] = fail_fast
+ return self
+
+
+func is_fail_fast() -> bool:
+ return _config.get(EXIT_FAIL_FAST, false)
+
+
+func add_test_cases(tests: Array[GdUnitTestCase]) -> GdUnitRunnerConfig:
+ test_cases().append_array(tests)
+ return self
+
+
+func test_cases() -> Array[GdUnitTestCase]:
+ return _config.get(TESTS, [])
+
+
+func save_config(path: String = CONFIG_FILE) -> GdUnitResult:
+ var file := FileAccess.open(path, FileAccess.WRITE)
+ if file == null:
+ var error := FileAccess.get_open_error()
+ return GdUnitResult.error("Can't write test runner configuration '%s'! %s" % [path, error_string(error)])
+
+ var to_save := {
+ VERSION : CONFIG_VERSION,
+ EXIT_FAIL_FAST : is_fail_fast(),
+ SERVER_PORT : server_port(),
+ TESTS : Array()
+ }
+
+ var tests: Array = to_save.get(TESTS)
+ for test in test_cases():
+ tests.append(inst_to_dict(test))
+ file.store_string(JSON.stringify(to_save, "\t"))
+ return GdUnitResult.success(path)
+
+
+func load_config(path: String = CONFIG_FILE) -> GdUnitResult:
+ if not FileAccess.file_exists(path):
+ return GdUnitResult.warn("Can't find test runner configuration '%s'! Please select a test to run." % path)
+ var file := FileAccess.open(path, FileAccess.READ)
+ if file == null:
+ var error := FileAccess.get_open_error()
+ return GdUnitResult.error("Can't load test runner configuration '%s'! ERROR: %s." % [path, error_string(error)])
+ var content := file.get_as_text()
+ if not content.is_empty() and content[0] == '{':
+ # Parse as json
+ var test_json_conv := JSON.new()
+ var error := test_json_conv.parse(content)
+ if error != OK:
+ return GdUnitResult.error("The runner configuration '%s' is invalid! The format is changed please delete it manually and start a new test run." % path)
+ var config: Dictionary = test_json_conv.get_data()
+ if not config.has(VERSION):
+ return GdUnitResult.error("The runner configuration '%s' is invalid! The format is changed please delete it manually and start a new test run." % path)
+
+ var default: Array[Dictionary] = Array([], TYPE_DICTIONARY, "", null)
+ var tests_as_json: Array = config.get(TESTS, default)
+ _config = config
+ _config[TESTS] = convert_test_json_to_test_cases(tests_as_json)
+
+
+ fix_value_types()
+ return GdUnitResult.success(path)
+
+
+func convert_test_json_to_test_cases(jsons: Array) -> Array[GdUnitTestCase]:
+ if jsons.is_empty():
+ return []
+ var tests := jsons.map(func(d: Dictionary) -> GdUnitTestCase:
+ var test: GdUnitTestCase = dict_to_inst(d)
+ # we need o covert manually to the corect type becaus JSON do not handle typed values
+ test.guid = GdUnitGUID.new(str(d["guid"]))
+ test.attribute_index = test.attribute_index as int
+ test.line_number = test.line_number as int
+ return test
+ )
+ return Array(tests, TYPE_OBJECT, "RefCounted", GdUnitTestCase)
+
+
+func fix_value_types() -> void:
+ # fix float value to int json stores all numbers as float
+ var server_port_: int = _config.get(SERVER_PORT, -1)
+ _config[SERVER_PORT] = server_port_
+
+
+func convert_Array_to_PackedStringArray(data: Dictionary) -> void:
+ for key in data.keys() as Array[String]:
+ var values :Array = data[key]
+ data[key] = PackedStringArray(values)
+
+
+func _to_string() -> String:
+ return str(_config)
diff --git a/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid
new file mode 100644
index 0000000..f35bac7
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitRunnerConfig.gd.uid
@@ -0,0 +1 @@
+uid://c4624ta2v365l
diff --git a/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd
new file mode 100644
index 0000000..5c85252
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd
@@ -0,0 +1,642 @@
+# This class provides a runner for scense to simulate interactions like keyboard or mouse
+class_name GdUnitSceneRunnerImpl
+extends GdUnitSceneRunner
+
+
+var GdUnitFuncAssertImpl: GDScript = ResourceLoader.load("res://addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd", "GDScript", ResourceLoader.CACHE_MODE_REUSE)
+
+
+# mapping of mouse buttons and his masks
+const MAP_MOUSE_BUTTON_MASKS := {
+ MOUSE_BUTTON_LEFT : MOUSE_BUTTON_MASK_LEFT,
+ MOUSE_BUTTON_RIGHT : MOUSE_BUTTON_MASK_RIGHT,
+ MOUSE_BUTTON_MIDDLE : MOUSE_BUTTON_MASK_MIDDLE,
+ # https://github.com/godotengine/godot/issues/73632
+ MOUSE_BUTTON_WHEEL_UP : 1 << (MOUSE_BUTTON_WHEEL_UP - 1),
+ MOUSE_BUTTON_WHEEL_DOWN : 1 << (MOUSE_BUTTON_WHEEL_DOWN - 1),
+ MOUSE_BUTTON_XBUTTON1 : MOUSE_BUTTON_MASK_MB_XBUTTON1,
+ MOUSE_BUTTON_XBUTTON2 : MOUSE_BUTTON_MASK_MB_XBUTTON2,
+}
+
+var _is_disposed := false
+var _current_scene: Node = null
+var _awaiter: GdUnitAwaiter = GdUnitAwaiter.new()
+var _verbose: bool
+var _simulate_start_time: LocalTime
+var _last_input_event: InputEvent = null
+var _mouse_button_on_press := []
+var _key_on_press := []
+var _action_on_press := []
+var _curent_mouse_position: Vector2
+# holds the touch position for each touch index
+# { index: int = position: Vector2}
+var _current_touch_position: Dictionary = {}
+# holds the curretn touch drag position
+var _current_touch_drag_position: Vector2 = Vector2.ZERO
+
+# time factor settings
+var _time_factor := 1.0
+var _saved_iterations_per_second: float
+var _scene_auto_free := false
+
+
+func _init(p_scene: Variant, p_verbose: bool, p_hide_push_errors := false) -> void:
+ _verbose = p_verbose
+ _saved_iterations_per_second = Engine.get_physics_ticks_per_second()
+ @warning_ignore("return_value_discarded")
+ set_time_factor(1)
+ # handle scene loading by resource path
+ if typeof(p_scene) == TYPE_STRING:
+ @warning_ignore("unsafe_cast")
+ if !ResourceLoader.exists(p_scene as String):
+ if not p_hide_push_errors:
+ push_error("GdUnitSceneRunner: Can't load scene by given resource path: '%s'. The resource does not exists." % p_scene)
+ return
+ if !str(p_scene).ends_with(".tscn") and !str(p_scene).ends_with(".scn") and !str(p_scene).begins_with("uid://"):
+ if not p_hide_push_errors:
+ push_error("GdUnitSceneRunner: The given resource: '%s'. is not a scene." % p_scene)
+ return
+ @warning_ignore("unsafe_cast")
+ _current_scene = (load(p_scene as String) as PackedScene).instantiate()
+ _scene_auto_free = true
+ else:
+ # verify we have a node instance
+ if not p_scene is Node:
+ if not p_hide_push_errors:
+ push_error("GdUnitSceneRunner: The given instance '%s' is not a Node." % p_scene)
+ return
+ _current_scene = p_scene
+ if _current_scene == null:
+ if not p_hide_push_errors:
+ push_error("GdUnitSceneRunner: Scene must be not null!")
+ return
+
+ _scene_tree().root.add_child(_current_scene)
+ Engine.set_meta("GdUnitSceneRunner", self)
+ # do finally reset all open input events when the scene is removed
+ @warning_ignore("return_value_discarded")
+ _scene_tree().root.child_exiting_tree.connect(func f(child: Node) -> void:
+ if child == _current_scene:
+ _reset_input_to_default()
+ )
+ _simulate_start_time = LocalTime.now()
+ # we need to set inital a valid window otherwise the warp_mouse() is not handled
+ move_window_to_foreground()
+
+ # set inital mouse pos to 0,0
+ var max_iteration_to_wait := 0
+ while get_global_mouse_position() != Vector2.ZERO and max_iteration_to_wait < 100:
+ Input.warp_mouse(Vector2.ZERO)
+ max_iteration_to_wait += 1
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE and is_instance_valid(self):
+ # reset time factor to normal
+ __deactivate_time_factor()
+ if is_instance_valid(_current_scene):
+ move_window_to_background()
+ _scene_tree().root.remove_child(_current_scene)
+ # do only free scenes instanciated by this runner
+ if _scene_auto_free:
+ _current_scene.free()
+ _is_disposed = true
+ _current_scene = null
+ Engine.remove_meta("GdUnitSceneRunner")
+
+
+func _scene_tree() -> SceneTree:
+ return Engine.get_main_loop() as SceneTree
+
+
+func await_input_processed() -> void:
+ if scene() != null and scene().process_mode != Node.PROCESS_MODE_DISABLED:
+ Input.flush_buffered_events()
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ await (Engine.get_main_loop() as SceneTree).physics_frame
+
+
+@warning_ignore("return_value_discarded")
+func simulate_action_pressed(action: String, event_index := -1) -> GdUnitSceneRunner:
+ simulate_action_press(action, event_index)
+ simulate_action_release(action, event_index)
+ return self
+
+
+func simulate_action_press(action: String, event_index := -1) -> GdUnitSceneRunner:
+ __print_current_focus()
+ var event := InputEventAction.new()
+ event.pressed = true
+ event.action = action
+ event.event_index = event_index
+ _action_on_press.append(action)
+ return _handle_input_event(event)
+
+
+func simulate_action_release(action: String, event_index := -1) -> GdUnitSceneRunner:
+ __print_current_focus()
+ var event := InputEventAction.new()
+ event.pressed = false
+ event.action = action
+ event.event_index = event_index
+ _action_on_press.erase(action)
+ return _handle_input_event(event)
+
+
+@warning_ignore("return_value_discarded")
+func simulate_key_pressed(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner:
+ _push_warning_deprecated_arguments(shift_pressed, ctrl_pressed)
+ simulate_key_press(key_code, shift_pressed, ctrl_pressed)
+ await _scene_tree().process_frame
+ simulate_key_release(key_code, shift_pressed, ctrl_pressed)
+ return self
+
+
+func simulate_key_press(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner:
+ _push_warning_deprecated_arguments(shift_pressed, ctrl_pressed)
+ __print_current_focus()
+ var event := InputEventKey.new()
+ event.pressed = true
+ event.keycode = key_code as Key
+ event.physical_keycode = key_code as Key
+ event.unicode = key_code
+ event.set_alt_pressed(key_code == KEY_ALT)
+ event.set_shift_pressed(shift_pressed)
+ event.set_ctrl_pressed(ctrl_pressed)
+ event.get_modifiers_mask()
+ _apply_input_modifiers(event)
+ _key_on_press.append(key_code)
+ return _handle_input_event(event)
+
+
+func simulate_key_release(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner:
+ _push_warning_deprecated_arguments(shift_pressed, ctrl_pressed)
+ __print_current_focus()
+ var event := InputEventKey.new()
+ event.pressed = false
+ event.keycode = key_code as Key
+ event.physical_keycode = key_code as Key
+ event.unicode = key_code
+ event.set_alt_pressed(key_code == KEY_ALT)
+ event.set_shift_pressed(shift_pressed)
+ event.set_ctrl_pressed(ctrl_pressed)
+ _apply_input_modifiers(event)
+ _key_on_press.erase(key_code)
+ return _handle_input_event(event)
+
+
+func set_mouse_position(pos: Vector2) -> GdUnitSceneRunner:
+ var event := InputEventMouseMotion.new()
+ event.position = pos
+ event.global_position = get_global_mouse_position()
+ _apply_input_modifiers(event)
+ return _handle_input_event(event)
+
+
+func get_mouse_position() -> Vector2:
+ if _last_input_event is InputEventMouse:
+ return (_last_input_event as InputEventMouse).position
+ var current_scene := scene()
+ if current_scene != null:
+ return current_scene.get_viewport().get_mouse_position()
+ return Vector2.ZERO
+
+
+func get_global_mouse_position() -> Vector2:
+ return (Engine.get_main_loop() as SceneTree).root.get_mouse_position()
+
+
+func simulate_mouse_move(position: Vector2) -> GdUnitSceneRunner:
+ var event := InputEventMouseMotion.new()
+ event.position = position
+ event.relative = position - get_mouse_position()
+ event.global_position = get_global_mouse_position()
+ _apply_input_mouse_mask(event)
+ _apply_input_modifiers(event)
+ return _handle_input_event(event)
+
+
+@warning_ignore("return_value_discarded")
+func simulate_mouse_move_relative(relative: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner:
+ var tween := _scene_tree().create_tween()
+ _curent_mouse_position = get_mouse_position()
+ var final_position := _curent_mouse_position + relative
+ tween.tween_property(self, "_curent_mouse_position", final_position, time).set_trans(trans_type)
+ tween.play()
+
+ while not get_mouse_position().is_equal_approx(final_position):
+ simulate_mouse_move(_curent_mouse_position)
+ await _scene_tree().process_frame
+ return self
+
+
+@warning_ignore("return_value_discarded")
+func simulate_mouse_move_absolute(position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner:
+ var tween := _scene_tree().create_tween()
+ _curent_mouse_position = get_mouse_position()
+ tween.tween_property(self, "_curent_mouse_position", position, time).set_trans(trans_type)
+ tween.play()
+
+ while not get_mouse_position().is_equal_approx(position):
+ simulate_mouse_move(_curent_mouse_position)
+ await _scene_tree().process_frame
+ return self
+
+
+@warning_ignore("return_value_discarded")
+func simulate_mouse_button_pressed(button_index: MouseButton, double_click := false) -> GdUnitSceneRunner:
+ simulate_mouse_button_press(button_index, double_click)
+ simulate_mouse_button_release(button_index)
+ return self
+
+
+func simulate_mouse_button_press(button_index: MouseButton, double_click := false) -> GdUnitSceneRunner:
+ var event := InputEventMouseButton.new()
+ event.button_index = button_index
+ event.pressed = true
+ event.double_click = double_click
+ _apply_input_mouse_position(event)
+ _apply_input_mouse_mask(event)
+ _apply_input_modifiers(event)
+ _mouse_button_on_press.append(button_index)
+ return _handle_input_event(event)
+
+
+func simulate_mouse_button_release(button_index: MouseButton) -> GdUnitSceneRunner:
+ var event := InputEventMouseButton.new()
+ event.button_index = button_index
+ event.pressed = false
+ _apply_input_mouse_position(event)
+ _apply_input_mouse_mask(event)
+ _apply_input_modifiers(event)
+ _mouse_button_on_press.erase(button_index)
+ return _handle_input_event(event)
+
+
+@warning_ignore("return_value_discarded")
+func simulate_screen_touch_pressed(index: int, position: Vector2, double_tap := false) -> GdUnitSceneRunner:
+ simulate_screen_touch_press(index, position, double_tap)
+ simulate_screen_touch_release(index)
+ return self
+
+
+@warning_ignore("return_value_discarded")
+func simulate_screen_touch_press(index: int, position: Vector2, double_tap := false) -> GdUnitSceneRunner:
+ if is_emulate_mouse_from_touch():
+ # we need to simulate in addition to the touch the mouse events
+ set_mouse_position(position)
+ simulate_mouse_button_press(MOUSE_BUTTON_LEFT)
+ # push touch press event at position
+ var event := InputEventScreenTouch.new()
+ event.window_id = scene().get_window().get_window_id()
+ event.index = index
+ event.position = position
+ event.double_tap = double_tap
+ event.pressed = true
+ _current_scene.get_viewport().push_input(event)
+ # save current drag position by index
+ _current_touch_position[index] = position
+ return self
+
+
+@warning_ignore("return_value_discarded")
+func simulate_screen_touch_release(index: int, double_tap := false) -> GdUnitSceneRunner:
+ if is_emulate_mouse_from_touch():
+ # we need to simulate in addition to the touch the mouse events
+ simulate_mouse_button_release(MOUSE_BUTTON_LEFT)
+ # push touch release event at position
+ var event := InputEventScreenTouch.new()
+ event.window_id = scene().get_window().get_window_id()
+ event.index = index
+ event.position = get_screen_touch_drag_position(index)
+ event.pressed = false
+ event.double_tap = (_last_input_event as InputEventScreenTouch).double_tap if _last_input_event is InputEventScreenTouch else double_tap
+ _current_scene.get_viewport().push_input(event)
+ return self
+
+
+func simulate_screen_touch_drag_relative(index: int, relative: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner:
+ var current_position: Vector2 = _current_touch_position[index]
+ return await _do_touch_drag_at(index, current_position + relative, time, trans_type)
+
+
+func simulate_screen_touch_drag_absolute(index: int, position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner:
+ return await _do_touch_drag_at(index, position, time, trans_type)
+
+
+@warning_ignore("return_value_discarded")
+func simulate_screen_touch_drag_drop(index: int, position: Vector2, drop_position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner:
+ simulate_screen_touch_press(index, position)
+ return await _do_touch_drag_at(index, drop_position, time, trans_type)
+
+
+@warning_ignore("return_value_discarded")
+func simulate_screen_touch_drag(index: int, position: Vector2) -> GdUnitSceneRunner:
+ if is_emulate_mouse_from_touch():
+ simulate_mouse_move(position)
+ var event := InputEventScreenDrag.new()
+ event.window_id = scene().get_window().get_window_id()
+ event.index = index
+ event.position = position
+ event.relative = _get_screen_touch_drag_position_or_default(index, position) - position
+ event.velocity = event.relative / _scene_tree().root.get_process_delta_time()
+ event.pressure = 1.0
+ _current_touch_position[index] = position
+ _current_scene.get_viewport().push_input(event)
+ return self
+
+
+func get_screen_touch_drag_position(index: int) -> Vector2:
+ if _current_touch_position.has(index):
+ return _current_touch_position[index]
+ push_error("No touch drag position for index '%d' is set!" % index)
+ return Vector2.ZERO
+
+
+func is_emulate_mouse_from_touch() -> bool:
+ return ProjectSettings.get_setting("input_devices/pointing/emulate_mouse_from_touch", true)
+
+
+func _get_screen_touch_drag_position_or_default(index: int, default_position: Vector2) -> Vector2:
+ if _current_touch_position.has(index):
+ return _current_touch_position[index]
+ return default_position
+
+
+@warning_ignore("return_value_discarded")
+func _do_touch_drag_at(index: int, drag_position: Vector2, time: float, trans_type: Tween.TransitionType) -> GdUnitSceneRunner:
+ # start draging
+ var event := InputEventScreenDrag.new()
+ event.window_id = scene().get_window().get_window_id()
+ event.index = index
+ event.position = get_screen_touch_drag_position(index)
+ event.pressure = 1.0
+ _current_touch_drag_position = event.position
+
+ var tween := _scene_tree().create_tween()
+ tween.tween_property(self, "_current_touch_drag_position", drag_position, time).set_trans(trans_type)
+ tween.play()
+
+ while not _current_touch_drag_position.is_equal_approx(drag_position):
+ if is_emulate_mouse_from_touch():
+ # we need to simulate in addition to the drag the mouse move events
+ simulate_mouse_move(event.position)
+ # send touche drag event to new position
+ event.relative = _current_touch_drag_position - event.position
+ event.velocity = event.relative / _scene_tree().root.get_process_delta_time()
+ event.position = _current_touch_drag_position
+ _current_scene.get_viewport().push_input(event)
+ await _scene_tree().process_frame
+
+ # finaly drop it
+ if is_emulate_mouse_from_touch():
+ simulate_mouse_move(drag_position)
+ simulate_mouse_button_release(MOUSE_BUTTON_LEFT)
+ var touch_drop_event := InputEventScreenTouch.new()
+ touch_drop_event.window_id = event.window_id
+ touch_drop_event.index = event.index
+ touch_drop_event.position = drag_position
+ touch_drop_event.pressed = false
+ _current_scene.get_viewport().push_input(touch_drop_event)
+ await _scene_tree().process_frame
+ return self
+
+
+func set_time_factor(time_factor: float = 1.0) -> GdUnitSceneRunner:
+ _time_factor = min(9.0, time_factor)
+ __activate_time_factor()
+ __print("set time factor: %f" % _time_factor)
+ __print("set physics physics_ticks_per_second: %d" % (_saved_iterations_per_second*_time_factor))
+ return self
+
+
+func simulate_frames(frames: int, delta_milli: int = -1) -> GdUnitSceneRunner:
+ var time_shift_frames :int = max(1, frames / _time_factor)
+ for frame in time_shift_frames:
+ if delta_milli == -1:
+ await _scene_tree().process_frame
+ else:
+ await _scene_tree().create_timer(delta_milli * 0.001).timeout
+ return self
+
+
+func simulate_until_signal(signal_name: String, ...args: Array) -> GdUnitSceneRunner:
+ await _awaiter.await_signal_idle_frames(scene(), signal_name, args, 10000)
+ return self
+
+
+func simulate_until_object_signal(source: Object, signal_name: String, ...args: Array) -> GdUnitSceneRunner:
+ await _awaiter.await_signal_idle_frames(source, signal_name, args, 10000)
+ return self
+
+
+func await_func(func_name: String, ...args: Array) -> GdUnitFuncAssert:
+ return GdUnitFuncAssertImpl.new(scene(), func_name, args)
+
+
+func await_func_on(instance: Object, func_name: String, ...args: Array) -> GdUnitFuncAssert:
+ return GdUnitFuncAssertImpl.new(instance, func_name, args)
+
+
+func await_signal(signal_name: String, args := [], timeout := 2000 ) -> void:
+ await _awaiter.await_signal_on(scene(), signal_name, args, timeout)
+
+
+func await_signal_on(source: Object, signal_name: String, args := [], timeout := 2000 ) -> void:
+ await _awaiter.await_signal_on(source, signal_name, args, timeout)
+
+
+func move_window_to_foreground() -> GdUnitSceneRunner:
+ if not Engine.is_embedded_in_editor():
+ DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
+ DisplayServer.window_move_to_foreground()
+ return self
+
+
+func move_window_to_background() -> GdUnitSceneRunner:
+ if not Engine.is_embedded_in_editor():
+ DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
+ DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
+ return self
+
+
+func _property_exists(name: String) -> bool:
+ return scene().get_property_list().any(func(properties :Dictionary) -> bool: return properties["name"] == name)
+
+
+func get_property(name: String) -> Variant:
+ if not _property_exists(name):
+ return "The property '%s' not exist checked loaded scene." % name
+ return scene().get(name)
+
+
+func set_property(name: String, value: Variant) -> bool:
+ if not _property_exists(name):
+ push_error("The property named '%s' cannot be set, it does not exist!" % name)
+ return false;
+ scene().set(name, value)
+ return true
+
+
+func invoke(name: String, ...args: Array) -> Variant:
+ if scene().has_method(name):
+ return await scene().callv(name, args)
+ return "The method '%s' not exist checked loaded scene." % name
+
+
+func find_child(name: String, recursive: bool = true, owned: bool = false) -> Node:
+ return scene().find_child(name, recursive, owned)
+
+
+func _scene_name() -> String:
+ if scene() == null:
+ return "unknown"
+ var scene_script :GDScript = scene().get_script()
+ var scene_name :String = scene().get_name()
+ if not scene_script:
+ return scene_name
+ if not scene_name.begins_with("@"):
+ return scene_name
+ return scene_script.resource_name.get_basename()
+
+
+func __activate_time_factor() -> void:
+ Engine.set_time_scale(_time_factor)
+ Engine.set_physics_ticks_per_second((_saved_iterations_per_second * _time_factor) as int)
+
+
+func __deactivate_time_factor() -> void:
+ Engine.set_time_scale(1)
+ Engine.set_physics_ticks_per_second(_saved_iterations_per_second as int)
+
+
+# copy over current active modifiers
+func _apply_input_modifiers(event: InputEvent) -> void:
+ if _last_input_event is InputEventWithModifiers and event is InputEventWithModifiers:
+ var last_input_event := _last_input_event as InputEventWithModifiers
+ var _event := event as InputEventWithModifiers
+ _event.meta_pressed = _event.meta_pressed or last_input_event.meta_pressed
+ _event.alt_pressed = _event.alt_pressed or last_input_event.alt_pressed
+ _event.shift_pressed = _event.shift_pressed or last_input_event.shift_pressed
+ _event.ctrl_pressed = _event.ctrl_pressed or last_input_event.ctrl_pressed
+ # this line results into reset the control_pressed state!!!
+ #event.command_or_control_autoremap = event.command_or_control_autoremap or _last_input_event.command_or_control_autoremap
+ if _last_input_event is InputEventKey and event is InputEventWithModifiers:
+ var last_input_event := _last_input_event as InputEventKey
+ var _event := event as InputEventWithModifiers
+ _event.shift_pressed = _event.shift_pressed or last_input_event.keycode == KEY_SHIFT
+ _event.alt_pressed = _event.alt_pressed or last_input_event.keycode == KEY_ALT
+ _event.ctrl_pressed = _event.ctrl_pressed or last_input_event.keycode == KEY_CTRL
+ _event.meta_pressed = _event.meta_pressed or last_input_event.keycode == KEY_META
+
+
+# copy over current active mouse mask and combine with curren mask
+func _apply_input_mouse_mask(event: InputEvent) -> void:
+ # first apply last mask
+ if _last_input_event is InputEventMouse and event is InputEventMouse:
+ (event as InputEventMouse).button_mask |= (_last_input_event as InputEventMouse).button_mask
+ if event is InputEventMouseButton:
+ var _event := event as InputEventMouseButton
+ var button_mask :int = MAP_MOUSE_BUTTON_MASKS.get(_event.get_button_index(), 0)
+ if _event.is_pressed():
+ _event.button_mask |= button_mask
+ else:
+ _event.button_mask ^= button_mask
+
+
+# copy over last mouse position if need
+func _apply_input_mouse_position(event: InputEvent) -> void:
+ if _last_input_event is InputEventMouse and event is InputEventMouseButton:
+ (event as InputEventMouseButton).position = (_last_input_event as InputEventMouse).position
+
+
+## handle input action via Input modifieres
+func _handle_actions(event: InputEventAction) -> bool:
+ if not InputMap.event_is_action(event, event.action, true):
+ return false
+ __print(" process action %s (%s) <- %s" % [scene(), _scene_name(), event.as_text()])
+ if event.is_pressed():
+ Input.action_press(event.action, event.get_strength())
+ else:
+ Input.action_release(event.action)
+ return true
+
+
+# for handling read https://docs.godotengine.org/en/stable/tutorials/inputs/inputevent.html?highlight=inputevent#how-does-it-work
+@warning_ignore("return_value_discarded")
+func _handle_input_event(event: InputEvent) -> GdUnitSceneRunner:
+ if event is InputEventMouse:
+ Input.warp_mouse((event as InputEventMouse).position as Vector2)
+ Input.parse_input_event(event)
+
+ if event is InputEventAction:
+ _handle_actions(event as InputEventAction)
+
+ var current_scene := scene()
+ if is_instance_valid(current_scene):
+ # do not flush events if node processing disabled otherwise we run into errors at tree removed
+ if _current_scene.process_mode != Node.PROCESS_MODE_DISABLED:
+ Input.flush_buffered_events()
+ __print(" process event %s (%s) <- %s" % [current_scene, _scene_name(), event.as_text()])
+ if(current_scene.has_method("_gui_input")):
+ (current_scene as Control)._gui_input(event)
+ if(current_scene.has_method("_unhandled_input")):
+ current_scene._unhandled_input(event)
+ current_scene.get_viewport().set_input_as_handled()
+
+ # save last input event needs to be merged with next InputEventMouseButton
+ _last_input_event = event
+ return self
+
+
+@warning_ignore("return_value_discarded")
+func _reset_input_to_default() -> void:
+ # reset all mouse button to inital state if need
+ for m_button :int in _mouse_button_on_press.duplicate():
+ if Input.is_mouse_button_pressed(m_button):
+ simulate_mouse_button_release(m_button)
+ _mouse_button_on_press.clear()
+
+ for key_scancode :int in _key_on_press.duplicate():
+ if Input.is_key_pressed(key_scancode):
+ simulate_key_release(key_scancode)
+ _key_on_press.clear()
+
+ for action :String in _action_on_press.duplicate():
+ if Input.is_action_pressed(action):
+ simulate_action_release(action)
+ _action_on_press.clear()
+
+ if is_instance_valid(_current_scene) and _current_scene.process_mode != Node.PROCESS_MODE_DISABLED:
+ Input.flush_buffered_events()
+ _last_input_event = null
+
+
+func __print(message: String) -> void:
+ if _verbose:
+ prints(message)
+
+
+func __print_current_focus() -> void:
+ if not _verbose:
+ return
+ var focused_node := scene().get_viewport().gui_get_focus_owner()
+ if focused_node:
+ prints(" focus checked %s" % focused_node)
+ else:
+ prints(" no focus set")
+
+
+func scene() -> Node:
+ if is_instance_valid(_current_scene):
+ return _current_scene
+ if not _is_disposed:
+ push_error("The current scene instance is not valid anymore! check your test is valid. e.g. check for missing awaits.")
+ return null
+
+
+func _push_warning_deprecated_arguments(shift_pressed: bool, ctrl_pressed: bool) -> void:
+ if shift_pressed:
+ push_warning("Deprecated! Don't use 'shift_pressed' it will be removed in v7.0, checkout the documentaion how to use key combinations.")
+ if ctrl_pressed:
+ push_warning("Deprecated! Don't use 'ctrl_pressed' it will be removed in v7.0, checkout the documentaion how to use key combinations.")
diff --git a/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid
new file mode 100644
index 0000000..a758b3b
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd.uid
@@ -0,0 +1 @@
+uid://qogbhj2t2ngs
diff --git a/addons/gdUnit4/src/core/GdUnitSettings.gd b/addons/gdUnit4/src/core/GdUnitSettings.gd
new file mode 100644
index 0000000..c4eaa96
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSettings.gd
@@ -0,0 +1,443 @@
+@tool
+class_name GdUnitSettings
+extends RefCounted
+
+
+const MAIN_CATEGORY = "gdunit4"
+# Common Settings
+const COMMON_SETTINGS = MAIN_CATEGORY + "/settings"
+
+const GROUP_COMMON = COMMON_SETTINGS + "/common"
+const UPDATE_NOTIFICATION_ENABLED = GROUP_COMMON + "/update_notification_enabled"
+const SERVER_TIMEOUT = GROUP_COMMON + "/server_connection_timeout_minutes"
+
+const GROUP_HOOKS = MAIN_CATEGORY + "/hooks"
+const SESSION_HOOKS = GROUP_HOOKS + "/session_hooks"
+
+const GROUP_TEST = COMMON_SETTINGS + "/test"
+const TEST_TIMEOUT = GROUP_TEST + "/test_timeout_seconds"
+const TEST_LOOKUP_FOLDER = GROUP_TEST + "/test_lookup_folder"
+const TEST_SUITE_NAMING_CONVENTION = GROUP_TEST + "/test_suite_naming_convention"
+const TEST_DISCOVER_ENABLED = GROUP_TEST + "/test_discovery"
+const TEST_FLAKY_CHECK = GROUP_TEST + "/flaky_check_enable"
+const TEST_FLAKY_MAX_RETRIES = GROUP_TEST + "/flaky_max_retries"
+const TEST_RERUN_UNTIL_FAILURE_RETRIES = GROUP_TEST + "/rerun_until_failure_retries"
+
+
+# Report Setiings
+const REPORT_SETTINGS = MAIN_CATEGORY + "/report"
+const GROUP_GODOT = REPORT_SETTINGS + "/godot"
+const REPORT_PUSH_ERRORS = GROUP_GODOT + "/push_error"
+const REPORT_SCRIPT_ERRORS = GROUP_GODOT + "/script_error"
+const REPORT_ORPHANS = REPORT_SETTINGS + "/verbose_orphans"
+const GROUP_ASSERT = REPORT_SETTINGS + "/assert"
+const REPORT_ASSERT_WARNINGS = GROUP_ASSERT + "/verbose_warnings"
+const REPORT_ASSERT_ERRORS = GROUP_ASSERT + "/verbose_errors"
+const REPORT_ASSERT_STRICT_NUMBER_TYPE_COMPARE = GROUP_ASSERT + "/strict_number_type_compare"
+
+# Godot debug stdout/logging settings
+const CATEGORY_LOGGING := "debug/file_logging/"
+const STDOUT_ENABLE_TO_FILE = CATEGORY_LOGGING + "enable_file_logging"
+const STDOUT_WITE_TO_FILE = CATEGORY_LOGGING + "log_path"
+
+
+# GdUnit Templates
+const TEMPLATES = MAIN_CATEGORY + "/templates"
+const TEMPLATES_TS = TEMPLATES + "/testsuite"
+const TEMPLATE_TS_GD = TEMPLATES_TS + "/GDScript"
+const TEMPLATE_TS_CS = TEMPLATES_TS + "/CSharpScript"
+
+
+# UI Setiings
+const UI_SETTINGS = MAIN_CATEGORY + "/ui"
+const GROUP_UI_INSPECTOR = UI_SETTINGS + "/inspector"
+const INSPECTOR_NODE_COLLAPSE = GROUP_UI_INSPECTOR + "/node_collapse"
+const INSPECTOR_TREE_VIEW_MODE = GROUP_UI_INSPECTOR + "/tree_view_mode"
+const INSPECTOR_TREE_SORT_MODE = GROUP_UI_INSPECTOR + "/tree_sort_mode"
+
+
+# Shortcut Setiings
+const SHORTCUT_SETTINGS = MAIN_CATEGORY + "/Shortcuts"
+const GROUP_SHORTCUT_INSPECTOR = SHORTCUT_SETTINGS + "/inspector"
+const SHORTCUT_INSPECTOR_RERUN_TEST = GROUP_SHORTCUT_INSPECTOR + "/rerun_test"
+const SHORTCUT_INSPECTOR_RERUN_TEST_DEBUG = GROUP_SHORTCUT_INSPECTOR + "/rerun_test_debug"
+const SHORTCUT_INSPECTOR_RUN_TEST_OVERALL = GROUP_SHORTCUT_INSPECTOR + "/run_test_overall"
+const SHORTCUT_INSPECTOR_RUN_TEST_STOP = GROUP_SHORTCUT_INSPECTOR + "/run_test_stop"
+const SHORTCUT_INSPECTOR_RERUN_TEST_UNTIL_FAILURE = GROUP_SHORTCUT_INSPECTOR + "/rerun_test_until_failure"
+
+const GROUP_SHORTCUT_EDITOR = SHORTCUT_SETTINGS + "/editor"
+const SHORTCUT_EDITOR_RUN_TEST = GROUP_SHORTCUT_EDITOR + "/run_test"
+const SHORTCUT_EDITOR_RUN_TEST_DEBUG = GROUP_SHORTCUT_EDITOR + "/run_test_debug"
+const SHORTCUT_EDITOR_CREATE_TEST = GROUP_SHORTCUT_EDITOR + "/create_test"
+
+const GROUP_SHORTCUT_FILESYSTEM = SHORTCUT_SETTINGS + "/filesystem"
+const SHORTCUT_FILESYSTEM_RUN_TEST = GROUP_SHORTCUT_FILESYSTEM + "/run_test"
+const SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG = GROUP_SHORTCUT_FILESYSTEM + "/run_test_debug"
+
+
+# Toolbar Setiings
+const GROUP_UI_TOOLBAR = UI_SETTINGS + "/toolbar"
+const INSPECTOR_TOOLBAR_BUTTON_RUN_OVERALL = GROUP_UI_TOOLBAR + "/run_overall"
+
+# Feature flags
+const GROUP_FEATURE = MAIN_CATEGORY + "/feature"
+
+
+# defaults
+# server connection timeout in minutes
+const DEFAULT_SERVER_TIMEOUT :int = 30
+# test case runtime timeout in seconds
+const DEFAULT_TEST_TIMEOUT :int = 60*5
+# the folder to create new test-suites
+const DEFAULT_TEST_LOOKUP_FOLDER := "test"
+
+# help texts
+const HELP_TEST_LOOKUP_FOLDER := "Subfolder where test suites are located (or empty to use source folder directly)"
+
+enum NAMING_CONVENTIONS {
+ AUTO_DETECT,
+ SNAKE_CASE,
+ PASCAL_CASE,
+}
+
+
+const _VALUE_SET_SEPARATOR = "\f" # ASCII Form-feed character (AKA page break)
+
+
+static func setup() -> void:
+ create_property_if_need(UPDATE_NOTIFICATION_ENABLED, true, "Show notification if new gdUnit4 version is found")
+ # test settings
+ create_property_if_need(SERVER_TIMEOUT, DEFAULT_SERVER_TIMEOUT, "Server connection timeout in minutes")
+ create_property_if_need(TEST_TIMEOUT, DEFAULT_TEST_TIMEOUT, "Test case runtime timeout in seconds")
+ create_property_if_need(TEST_LOOKUP_FOLDER, DEFAULT_TEST_LOOKUP_FOLDER, HELP_TEST_LOOKUP_FOLDER)
+ create_property_if_need(TEST_SUITE_NAMING_CONVENTION, NAMING_CONVENTIONS.AUTO_DETECT, "Naming convention to use when generating testsuites", NAMING_CONVENTIONS.keys())
+ create_property_if_need(TEST_DISCOVER_ENABLED, false, "Automatically detect new tests in test lookup folders at runtime")
+ create_property_if_need(TEST_FLAKY_CHECK, false, "Rerun tests on failure and mark them as FLAKY")
+ create_property_if_need(TEST_FLAKY_MAX_RETRIES, 3, "Sets the number of retries for rerunning a flaky test")
+ create_property_if_need(TEST_RERUN_UNTIL_FAILURE_RETRIES, 10, "The number of reruns until the test fails.")
+ # report settings
+ create_property_if_need(REPORT_PUSH_ERRORS, false, "Report push_error() as failure")
+ create_property_if_need(REPORT_SCRIPT_ERRORS, true, "Report script errors as failure")
+ create_property_if_need(REPORT_ORPHANS, true, "Report orphaned nodes after tests finish")
+ create_property_if_need(REPORT_ASSERT_ERRORS, true, "Report assertion failures as errors")
+ create_property_if_need(REPORT_ASSERT_WARNINGS, true, "Report assertion failures as warnings")
+ create_property_if_need(REPORT_ASSERT_STRICT_NUMBER_TYPE_COMPARE, true, "Compare number values strictly by type (real vs int)")
+ # inspector
+ create_property_if_need(INSPECTOR_NODE_COLLAPSE, true,
+ "Close testsuite node after a successful test run.")
+ create_property_if_need(INSPECTOR_TREE_VIEW_MODE, GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE,
+ "Inspector panel presentation mode", GdUnitInspectorTreeConstants.TREE_VIEW_MODE.keys())
+ create_property_if_need(INSPECTOR_TREE_SORT_MODE, GdUnitInspectorTreeConstants.SORT_MODE.UNSORTED,
+ "Inspector panel sorting mode", GdUnitInspectorTreeConstants.SORT_MODE.keys())
+ create_property_if_need(INSPECTOR_TOOLBAR_BUTTON_RUN_OVERALL, false,
+ "Show 'Run overall Tests' button in the inspector toolbar")
+ create_property_if_need(TEMPLATE_TS_GD, GdUnitTestSuiteTemplate.default_GD_template(), "Test suite template to use")
+ create_shortcut_properties_if_need()
+ create_property_if_need(SESSION_HOOKS, {} as Dictionary[String,bool])
+ migrate_properties()
+
+
+static func migrate_properties() -> void:
+ var TEST_ROOT_FOLDER := "gdunit4/settings/test/test_root_folder"
+ if get_property(TEST_ROOT_FOLDER) != null:
+ migrate_property(TEST_ROOT_FOLDER,\
+ TEST_LOOKUP_FOLDER,\
+ DEFAULT_TEST_LOOKUP_FOLDER,\
+ HELP_TEST_LOOKUP_FOLDER,\
+ func(value :Variant) -> String: return DEFAULT_TEST_LOOKUP_FOLDER if value == null else value)
+
+
+static func create_shortcut_properties_if_need() -> void:
+ # inspector
+ create_property_if_need(SHORTCUT_INSPECTOR_RERUN_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RERUN_TESTS), "Rerun the most recently executed tests")
+ create_property_if_need(SHORTCUT_INSPECTOR_RERUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RERUN_TESTS_DEBUG), "Rerun the most recently executed tests (Debug mode)")
+ create_property_if_need(SHORTCUT_INSPECTOR_RERUN_TEST_UNTIL_FAILURE, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RERUN_TESTS_UNTIL_FAILURE), "Rerun tests until failure occurs")
+ create_property_if_need(SHORTCUT_INSPECTOR_RUN_TEST_OVERALL, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTS_OVERALL), "Runs all tests (Debug mode)")
+ create_property_if_need(SHORTCUT_INSPECTOR_RUN_TEST_STOP, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.STOP_TEST_RUN), "Stop the current test execution")
+ # script editor
+ create_property_if_need(SHORTCUT_EDITOR_RUN_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTCASE), "Run the currently selected test")
+ create_property_if_need(SHORTCUT_EDITOR_RUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTCASE_DEBUG), "Run the currently selected test (Debug mode).")
+ create_property_if_need(SHORTCUT_EDITOR_CREATE_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.CREATE_TEST), "Create a new test case for the currently selected function")
+ # filesystem
+ create_property_if_need(SHORTCUT_FILESYSTEM_RUN_TEST, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTSUITE), "Run all test suites in the selected folder or file")
+ create_property_if_need(SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG, GdUnitShortcut.default_keys(GdUnitShortcut.ShortCut.RUN_TESTSUITE_DEBUG), "Run all test suites in the selected folder or file (Debug)")
+
+
+static func create_property_if_need(name :String, default :Variant, help :="", value_set := PackedStringArray()) -> void:
+ if not ProjectSettings.has_setting(name):
+ #prints("GdUnit4: Set inital settings '%s' to '%s'." % [name, str(default)])
+ ProjectSettings.set_setting(name, default)
+
+ ProjectSettings.set_initial_value(name, default)
+ help = help if value_set.is_empty() else "%s%s%s" % [help, _VALUE_SET_SEPARATOR, value_set]
+ set_help(name, default, help)
+
+
+static func set_help(property_name :String, value :Variant, help :String) -> void:
+ ProjectSettings.add_property_info({
+ "name": property_name,
+ "type": typeof(value),
+ "hint": PROPERTY_HINT_TYPE_STRING,
+ "hint_string": help
+ })
+
+
+static func get_setting(name :String, default :Variant) -> Variant:
+ if ProjectSettings.has_setting(name):
+ return ProjectSettings.get_setting(name)
+ return default
+
+
+static func is_update_notification_enabled() -> bool:
+ if ProjectSettings.has_setting(UPDATE_NOTIFICATION_ENABLED):
+ return ProjectSettings.get_setting(UPDATE_NOTIFICATION_ENABLED)
+ return false
+
+
+static func set_update_notification(enable :bool) -> void:
+ ProjectSettings.set_setting(UPDATE_NOTIFICATION_ENABLED, enable)
+ @warning_ignore("return_value_discarded")
+ ProjectSettings.save()
+
+
+static func get_log_path() -> String:
+ return ProjectSettings.get_setting(STDOUT_WITE_TO_FILE)
+
+
+static func set_log_path(path :String) -> void:
+ ProjectSettings.set_setting(STDOUT_ENABLE_TO_FILE, true)
+ ProjectSettings.set_setting(STDOUT_WITE_TO_FILE, path)
+ @warning_ignore("return_value_discarded")
+ ProjectSettings.save()
+
+
+static func get_session_hooks() -> Dictionary[String, bool]:
+ var property := get_property(SESSION_HOOKS)
+ if property == null:
+ return {}
+ var hooks: Dictionary[String, bool] = property.value()
+ return hooks
+
+
+static func set_session_hooks(hooks: Dictionary[String, bool]) -> void:
+ var property := get_property(SESSION_HOOKS)
+ property.set_value(hooks)
+ update_property(property)
+
+
+static func set_inspector_tree_sort_mode(sort_mode: GdUnitInspectorTreeConstants.SORT_MODE) -> void:
+ var property := get_property(INSPECTOR_TREE_SORT_MODE)
+ property.set_value(sort_mode)
+ update_property(property)
+
+
+static func get_inspector_tree_sort_mode() -> GdUnitInspectorTreeConstants.SORT_MODE:
+ var property := get_property(INSPECTOR_TREE_SORT_MODE)
+ return property.value() if property != null else GdUnitInspectorTreeConstants.SORT_MODE.UNSORTED
+
+
+static func set_inspector_tree_view_mode(tree_view_mode: GdUnitInspectorTreeConstants.TREE_VIEW_MODE) -> void:
+ var property := get_property(INSPECTOR_TREE_VIEW_MODE)
+ property.set_value(tree_view_mode)
+ update_property(property)
+
+
+static func get_inspector_tree_view_mode() -> GdUnitInspectorTreeConstants.TREE_VIEW_MODE:
+ var property := get_property(INSPECTOR_TREE_VIEW_MODE)
+ return property.value() if property != null else GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE
+
+
+# the configured server connection timeout in ms
+static func server_timeout() -> int:
+ return get_setting(SERVER_TIMEOUT, DEFAULT_SERVER_TIMEOUT) * 60 * 1000
+
+
+# the configured test case timeout in ms
+static func test_timeout() -> int:
+ return get_setting(TEST_TIMEOUT, DEFAULT_TEST_TIMEOUT) * 1000
+
+
+# the root folder to store/generate test-suites
+static func test_root_folder() -> String:
+ return get_setting(TEST_LOOKUP_FOLDER, DEFAULT_TEST_LOOKUP_FOLDER)
+
+
+static func is_verbose_assert_warnings() -> bool:
+ return get_setting(REPORT_ASSERT_WARNINGS, true)
+
+
+static func is_verbose_assert_errors() -> bool:
+ return get_setting(REPORT_ASSERT_ERRORS, true)
+
+
+static func is_verbose_orphans() -> bool:
+ return get_setting(REPORT_ORPHANS, true)
+
+
+static func is_strict_number_type_compare() -> bool:
+ return get_setting(REPORT_ASSERT_STRICT_NUMBER_TYPE_COMPARE, true)
+
+
+static func is_report_push_errors() -> bool:
+ return get_setting(REPORT_PUSH_ERRORS, false)
+
+
+static func is_report_script_errors() -> bool:
+ return get_setting(REPORT_SCRIPT_ERRORS, true)
+
+
+static func is_inspector_node_collapse() -> bool:
+ return get_setting(INSPECTOR_NODE_COLLAPSE, true)
+
+
+static func is_inspector_toolbar_button_show() -> bool:
+ return get_setting(INSPECTOR_TOOLBAR_BUTTON_RUN_OVERALL, true)
+
+
+static func is_test_discover_enabled() -> bool:
+ return get_setting(TEST_DISCOVER_ENABLED, false)
+
+
+static func is_test_flaky_check_enabled() -> bool:
+ return get_setting(TEST_FLAKY_CHECK, false)
+
+
+static func is_feature_enabled(feature: String) -> bool:
+ return get_setting(feature, false)
+
+
+static func get_flaky_max_retries() -> int:
+ return get_setting(TEST_FLAKY_MAX_RETRIES, 3)
+
+
+static func get_rerun_max_retries() -> int:
+ return get_setting(TEST_RERUN_UNTIL_FAILURE_RETRIES, 10)
+
+
+static func set_test_discover_enabled(enable :bool) -> void:
+ var property := get_property(TEST_DISCOVER_ENABLED)
+ property.set_value(enable)
+ update_property(property)
+
+
+static func is_log_enabled() -> bool:
+ return ProjectSettings.get_setting(STDOUT_ENABLE_TO_FILE)
+
+
+static func list_settings(category: String) -> Array[GdUnitProperty]:
+ var settings: Array[GdUnitProperty] = []
+ for property in ProjectSettings.get_property_list():
+ var property_name :String = property["name"]
+ if property_name.begins_with(category):
+ settings.append(build_property(property_name, property))
+ return settings
+
+
+static func extract_value_set_from_help(value :String) -> PackedStringArray:
+ var split_value := value.split(_VALUE_SET_SEPARATOR)
+ if not split_value.size() > 1:
+ return PackedStringArray()
+
+ var regex := RegEx.new()
+ @warning_ignore("return_value_discarded")
+ regex.compile("\\[(.+)\\]")
+ var matches := regex.search_all(split_value[1])
+ if matches.is_empty():
+ return PackedStringArray()
+ var values: String = matches[0].get_string(1)
+ return values.replacen(" ", "").replacen("\"", "").split(",", false)
+
+
+static func extract_help_text(value :String) -> String:
+ return value.split(_VALUE_SET_SEPARATOR)[0]
+
+
+static func update_property(property :GdUnitProperty) -> Variant:
+ var current_value :Variant = ProjectSettings.get_setting(property.name())
+ if current_value != property.value():
+ var error :Variant = validate_property_value(property)
+ if error != null:
+ return error
+ ProjectSettings.set_setting(property.name(), property.value())
+ GdUnitSignals.instance().gdunit_settings_changed.emit(property)
+ _save_settings()
+ return null
+
+
+static func reset_property(property :GdUnitProperty) -> void:
+ ProjectSettings.set_setting(property.name(), property.default())
+ GdUnitSignals.instance().gdunit_settings_changed.emit(property)
+ _save_settings()
+
+
+static func validate_property_value(property :GdUnitProperty) -> Variant:
+ match property.name():
+ TEST_LOOKUP_FOLDER:
+ return validate_lookup_folder(property.value_as_string())
+ _: return null
+
+
+static func validate_lookup_folder(value :String) -> Variant:
+ if value.is_empty() or value == "/":
+ return null
+ if value.contains("res:"):
+ return "Test Lookup Folder: do not allowed to contains 'res://'"
+ if not value.is_valid_filename():
+ return "Test Lookup Folder: contains invalid characters! e.g (: / \\ ? * \" | % < >)"
+ return null
+
+
+static func save_property(name :String, value :Variant) -> void:
+ ProjectSettings.set_setting(name, value)
+ _save_settings()
+
+
+static func _save_settings() -> void:
+ var err := ProjectSettings.save()
+ if err != OK:
+ push_error("Save GdUnit4 settings failed : %s" % error_string(err))
+ return
+
+
+static func has_property(name :String) -> bool:
+ return ProjectSettings.get_property_list().any(func(property :Dictionary) -> bool: return property["name"] == name)
+
+
+static func get_property(name :String) -> GdUnitProperty:
+ for property in ProjectSettings.get_property_list():
+ var property_name :String = property["name"]
+ if property_name == name:
+ return build_property(name, property)
+ return null
+
+
+static func build_property(property_name: String, property: Dictionary) -> GdUnitProperty:
+ var value: Variant = ProjectSettings.get_setting(property_name)
+ var value_type: int = property["type"]
+ var default: Variant = ProjectSettings.property_get_revert(property_name)
+ var help: String = property["hint_string"]
+ var value_set := extract_value_set_from_help(help)
+ return GdUnitProperty.new(property_name, value_type, value, default, extract_help_text(help), value_set)
+
+
+static func migrate_property(old_property :String, new_property :String, default_value :Variant, help :String, converter := Callable()) -> void:
+ var property := get_property(old_property)
+ if property == null:
+ prints("Migration not possible, property '%s' not found" % old_property)
+ return
+ var value :Variant = converter.call(property.value()) if converter.is_valid() else property.value()
+ ProjectSettings.set_setting(new_property, value)
+ ProjectSettings.set_initial_value(new_property, default_value)
+ set_help(new_property, value, help)
+ ProjectSettings.clear(old_property)
+ prints("Successfully migrated property '%s' -> '%s' value: %s" % [old_property, new_property, value])
+
+
+static func dump_to_tmp() -> void:
+ @warning_ignore("return_value_discarded")
+ ProjectSettings.save_custom("user://project_settings.godot")
+
+
+static func restore_dump_from_tmp() -> void:
+ @warning_ignore("return_value_discarded")
+ DirAccess.copy_absolute("user://project_settings.godot", "res://project.godot")
diff --git a/addons/gdUnit4/src/core/GdUnitSettings.gd.uid b/addons/gdUnit4/src/core/GdUnitSettings.gd.uid
new file mode 100644
index 0000000..3fcb866
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSettings.gd.uid
@@ -0,0 +1 @@
+uid://dvy5w5kr2i6e6
diff --git a/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd
new file mode 100644
index 0000000..528e133
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd
@@ -0,0 +1,81 @@
+class_name GdUnitSignalAwaiter
+extends RefCounted
+
+signal signal_emitted(action :Variant)
+
+const NO_ARG :Variant = GdUnitConstants.NO_ARG
+
+var _wait_on_idle_frame := false
+var _interrupted := false
+var _time_left :float = 0
+var _timeout_millis :int
+
+
+func _init(timeout_millis :int, wait_on_idle_frame := false) -> void:
+ _timeout_millis = timeout_millis
+ _wait_on_idle_frame = wait_on_idle_frame
+
+
+func _on_signal_emmited(
+ arg0 :Variant = NO_ARG,
+ arg1 :Variant = NO_ARG,
+ arg2 :Variant = NO_ARG,
+ arg3 :Variant = NO_ARG,
+ arg4 :Variant = NO_ARG,
+ arg5 :Variant = NO_ARG,
+ arg6 :Variant = NO_ARG,
+ arg7 :Variant = NO_ARG,
+ arg8 :Variant = NO_ARG,
+ arg9 :Variant = NO_ARG) -> void:
+ var signal_args :Variant = GdArrayTools.filter_value([arg0,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9], NO_ARG)
+ signal_emitted.emit(signal_args)
+
+
+func is_interrupted() -> bool:
+ return _interrupted
+
+
+func elapsed_time() -> float:
+ return _time_left
+
+
+func on_signal(source :Object, signal_name :String, expected_signal_args :Array) -> Variant:
+ # register checked signal to wait for
+ @warning_ignore("return_value_discarded")
+ source.connect(signal_name, _on_signal_emmited)
+ # install timeout timer
+ var scene_tree := Engine.get_main_loop() as SceneTree
+ var timer := Timer.new()
+ scene_tree.root.add_child(timer)
+ timer.add_to_group("GdUnitTimers")
+ timer.set_one_shot(true)
+ @warning_ignore("return_value_discarded")
+ timer.timeout.connect(_do_interrupt, CONNECT_DEFERRED)
+ timer.start(_timeout_millis * 0.001 * Engine.get_time_scale())
+
+ # holds the emited value
+ var value :Variant
+ # wait for signal is emitted or a timeout is happen
+ while true:
+ value = await signal_emitted
+ if _interrupted:
+ break
+ if not (value is Array):
+ value = [value]
+ if expected_signal_args.size() == 0 or GdObjects.equals(value, expected_signal_args):
+ break
+ await scene_tree.process_frame
+
+ source.disconnect(signal_name, _on_signal_emmited)
+ _time_left = timer.time_left
+ timer.queue_free()
+ await scene_tree.process_frame
+ @warning_ignore("unsafe_cast")
+ if value is Array and (value as Array).size() == 1:
+ return value[0]
+ return value
+
+
+func _do_interrupt() -> void:
+ _interrupted = true
+ signal_emitted.emit(null)
diff --git a/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid
new file mode 100644
index 0000000..366e962
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSignalAwaiter.gd.uid
@@ -0,0 +1 @@
+uid://ch6tenrmpno8w
diff --git a/addons/gdUnit4/src/core/GdUnitSignalCollector.gd b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd
new file mode 100644
index 0000000..0cb6ad7
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd
@@ -0,0 +1,129 @@
+# It connects to all signals of given emitter and collects received signals and arguments
+# The collected signals are cleand finally when the emitter is freed.
+class_name GdUnitSignalCollector
+extends RefCounted
+
+const NO_ARG :Variant = GdUnitConstants.NO_ARG
+const SIGNAL_BLACK_LIST = []#["tree_exiting", "tree_exited", "child_exiting_tree"]
+
+# {
+# emitter<Object> : {
+# signal_name<String> : [signal_args<Array>],
+# ...
+# }
+# }
+var _collected_signals :Dictionary = {}
+
+
+func clear() -> void:
+ for emitter :Object in _collected_signals.keys():
+ if is_instance_valid(emitter):
+ unregister_emitter(emitter)
+
+
+# connect to all possible signals defined by the emitter
+# prepares the signal collection to store received signals and arguments
+func register_emitter(emitter: Object, force_recreate := false) -> void:
+ if is_instance_valid(emitter):
+ # check emitter is already registerd
+ if _collected_signals.has(emitter):
+ if not force_recreate:
+ return
+ # If the flag recreate is set to true, emitters that are already registered must be deregistered before recreating,
+ # otherwise signals that have already been collected will be evaluated.
+ unregister_emitter(emitter)
+
+ _collected_signals[emitter] = Dictionary()
+ # connect to 'tree_exiting' of the emitter to finally release all acquired resources/connections.
+ if emitter is Node and !(emitter as Node).tree_exiting.is_connected(unregister_emitter):
+ (emitter as Node).tree_exiting.connect(unregister_emitter.bind(emitter))
+ # connect to all signals of the emitter we want to collect
+ for signal_def in emitter.get_signal_list():
+ var signal_name :String = signal_def["name"]
+ # set inital collected to empty
+ if not is_signal_collecting(emitter, signal_name):
+ _collected_signals[emitter][signal_name] = Array()
+ if SIGNAL_BLACK_LIST.find(signal_name) != -1:
+ continue
+ if !emitter.is_connected(signal_name, _on_signal_emmited):
+ var err := emitter.connect(signal_name, _on_signal_emmited.bind(emitter, signal_name))
+ if err != OK:
+ push_error("Can't connect to signal %s on %s. Error: %s" % [signal_name, emitter, error_string(err)])
+
+
+# unregister all acquired resources/connections, otherwise it ends up in orphans
+# is called when the emitter is removed from the parent
+func unregister_emitter(emitter :Object) -> void:
+ if is_instance_valid(emitter):
+ for signal_def in emitter.get_signal_list():
+ var signal_name :String = signal_def["name"]
+ if emitter.is_connected(signal_name, _on_signal_emmited):
+ emitter.disconnect(signal_name, _on_signal_emmited.bind(emitter, signal_name))
+ @warning_ignore("return_value_discarded")
+ _collected_signals.erase(emitter)
+
+
+# receives the signal from the emitter with all emitted signal arguments and additional the emitter and signal_name as last two arguements
+func _on_signal_emmited(
+ arg0 :Variant= NO_ARG,
+ arg1 :Variant= NO_ARG,
+ arg2 :Variant= NO_ARG,
+ arg3 :Variant= NO_ARG,
+ arg4 :Variant= NO_ARG,
+ arg5 :Variant= NO_ARG,
+ arg6 :Variant= NO_ARG,
+ arg7 :Variant= NO_ARG,
+ arg8 :Variant= NO_ARG,
+ arg9 :Variant= NO_ARG,
+ arg10 :Variant= NO_ARG,
+ arg11 :Variant= NO_ARG) -> void:
+ var signal_args :Array = GdArrayTools.filter_value([arg0,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9,arg10,arg11], NO_ARG)
+ # extract the emitter and signal_name from the last two arguments (see line 61 where is added)
+ var signal_name :String = signal_args.pop_back()
+ var emitter :Object = signal_args.pop_back()
+ #prints("_on_signal_emmited:", emitter, signal_name, signal_args)
+ if is_signal_collecting(emitter, signal_name):
+ @warning_ignore("unsafe_cast")
+ (_collected_signals[emitter][signal_name] as Array).append(signal_args)
+
+
+func reset_received_signals(emitter: Object, signal_name: String, signal_args: Array) -> void:
+ #_debug_signal_list("before claer");
+ if _collected_signals.has(emitter):
+ var signals_by_emitter :Dictionary = _collected_signals[emitter]
+ if signals_by_emitter.has(signal_name):
+ var received_args: Array = _collected_signals[emitter][signal_name]
+ # We iterate backwarts over to received_args to remove matching args.
+ # This will avoid array corruption see comment on `erase` otherwise we need a timeconsuming duplicate before
+ for arg_pos: int in range(received_args.size()-1, -1, -1):
+ var arg: Variant = received_args[arg_pos]
+ if GdObjects.equals(arg, signal_args):
+ received_args.remove_at(arg_pos)
+ #_debug_signal_list("after claer");
+
+
+func is_signal_collecting(emitter: Object, signal_name: String) -> bool:
+ @warning_ignore("unsafe_cast")
+ return _collected_signals.has(emitter) and (_collected_signals[emitter] as Dictionary).has(signal_name)
+
+
+func match(emitter: Object, signal_name: String, args: Array) -> bool:
+ #prints("match", signal_name, _collected_signals[emitter][signal_name]);
+ if _collected_signals.is_empty() or not _collected_signals.has(emitter):
+ return false
+ for received_args :Variant in _collected_signals[emitter][signal_name]:
+ #prints("testing", signal_name, received_args, "vs", args)
+ if GdObjects.equals(received_args, args):
+ return true
+ return false
+
+
+func _debug_signal_list(message :String) -> void:
+ prints("-----", message, "-------")
+ prints("senders {")
+ for emitter :Object in _collected_signals:
+ prints("\t", emitter)
+ for signal_name :String in _collected_signals[emitter]:
+ var args :Variant = _collected_signals[emitter][signal_name]
+ prints("\t\t", signal_name, args)
+ prints("}")
diff --git a/addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid
new file mode 100644
index 0000000..628debc
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSignalCollector.gd.uid
@@ -0,0 +1 @@
+uid://dgwkonrctse1
diff --git a/addons/gdUnit4/src/core/GdUnitSignals.gd b/addons/gdUnit4/src/core/GdUnitSignals.gd
new file mode 100644
index 0000000..f0a900d
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSignals.gd
@@ -0,0 +1,118 @@
+class_name GdUnitSignals
+extends RefCounted
+## Singleton class that handles GdUnit's signal communication.[br]
+## [br]
+## This class manages all signals used to communicate test events, discovery, and status changes.[br]
+## It uses a singleton pattern stored in Engine metadata to ensure a single instance.[br]
+## [br]
+## Signals are grouped by purpose:[br]
+## - Client connection handling[br]
+## - Test execution events[br]
+## - Test discovery events[br]
+## - Settings and status updates[br]
+## [br]
+## Example usage:[br]
+## [codeblock]
+## # Connect to test discovery
+## GdUnitSignals.instance().gdunit_test_discovered.connect(self._on_test_discovered)
+##
+## # Emit test event
+## GdUnitSignals.instance().gdunit_event.emit(test_event)
+## [/codeblock]
+
+
+## Emitted when a client connects to the GdUnit server.[br]
+## [param client_id] The ID of the connected client.
+@warning_ignore("unused_signal")
+signal gdunit_client_connected(client_id: int)
+
+
+## Emitted when a client disconnects from the GdUnit server.[br]
+## [param client_id] The ID of the disconnected client.
+@warning_ignore("unused_signal")
+signal gdunit_client_disconnected(client_id: int)
+
+
+## Emitted when a the user stops (terminates) the current test session
+@warning_ignore("unused_signal")
+signal gdunit_test_session_terminate()
+
+
+## Emitted when a test execution event occurs.[br]
+## [param event] The test event containing details about test execution.
+@warning_ignore("unused_signal")
+signal gdunit_event(event: GdUnitEvent)
+
+
+## Emitted for test debug events during execution.[br]
+## [param event] The debug event containing test execution details.
+@warning_ignore("unused_signal")
+signal gdunit_event_debug(event: GdUnitEvent)
+
+
+## Emitted to broadcast a general message.[br]
+## [param message] The message to broadcast.
+@warning_ignore("unused_signal")
+signal gdunit_message(message: String)
+
+
+## Emitted to update test failure status.[br]
+## [param is_failed] Whether the test has failed.
+@warning_ignore("unused_signal")
+signal gdunit_set_test_failed(is_failed: bool)
+
+
+## Emitted when a GdUnit setting changes.[br]
+## [param property] The property that was changed.
+@warning_ignore("unused_signal")
+signal gdunit_settings_changed(property: GdUnitProperty)
+
+## Called when a new test case is discovered during the discovery process.
+## Custom implementations should connect to this signal and store the discovered test case as needed.[br]
+## [param test_case] The discovered test case instance to be processed.
+@warning_ignore("unused_signal")
+signal gdunit_test_discover_added(test_case: GdUnitTestCase)
+
+
+## Emitted when a test case is deleted.[br]
+## [param test_case] The test case that was deleted.
+@warning_ignore("unused_signal")
+signal gdunit_test_discover_deleted(test_case: GdUnitTestCase)
+
+
+## Emitted when a test case is modified.[br]
+## [param test_case] The test case that was modified.
+@warning_ignore("unused_signal")
+signal gdunit_test_discover_modified(test_case: GdUnitTestCase)
+
+
+const META_KEY := "GdUnitSignals"
+
+
+## Returns the singleton instance of GdUnitSignals.[br]
+## Creates a new instance if none exists.[br]
+## [br]
+## Returns: The GdUnitSignals singleton instance.
+static func instance() -> GdUnitSignals:
+ if Engine.has_meta(META_KEY):
+ return Engine.get_meta(META_KEY)
+ var instance_ := GdUnitSignals.new()
+ Engine.set_meta(META_KEY, instance_)
+ return instance_
+
+
+## Cleans up the singleton instance and disconnects all signals.[br]
+## [br]
+## Should be called when GdUnit is shutting down or needs to reset.[br]
+## Ensures proper cleanup of signal connections and resources.
+static func dispose() -> void:
+ var signals := instance()
+ # cleanup connected signals
+ for signal_ in signals.get_signal_list():
+ @warning_ignore("unsafe_cast")
+ for connection in signals.get_signal_connection_list(signal_["name"] as StringName):
+ var _signal: Signal = connection["signal"]
+ var _callable: Callable = connection["callable"]
+ _signal.disconnect(_callable)
+ signals = null
+ Engine.remove_meta(META_KEY)
diff --git a/addons/gdUnit4/src/core/GdUnitSignals.gd.uid b/addons/gdUnit4/src/core/GdUnitSignals.gd.uid
new file mode 100644
index 0000000..d4ca8db
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSignals.gd.uid
@@ -0,0 +1 @@
+uid://071nom6h2642
diff --git a/addons/gdUnit4/src/core/GdUnitSingleton.gd b/addons/gdUnit4/src/core/GdUnitSingleton.gd
new file mode 100644
index 0000000..b8e08cc
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSingleton.gd
@@ -0,0 +1,56 @@
+################################################################################
+# Provides access to a global accessible singleton
+#
+# This is a workarount to the existing auto load singleton because of some bugs
+# around plugin handling
+################################################################################
+class_name GdUnitSingleton
+extends Object
+
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+const MEATA_KEY := "GdUnitSingletons"
+
+
+static func instance(name: String, clazz: Callable) -> Variant:
+ if Engine.has_meta(name):
+ return Engine.get_meta(name)
+ var singleton: Variant = clazz.call()
+ if is_instance_of(singleton, RefCounted):
+ @warning_ignore("unsafe_cast")
+ push_error("Invalid singleton implementation detected for '%s' is `%s`!" % [name, (singleton as RefCounted).get_class()])
+ return
+
+ Engine.set_meta(name, singleton)
+ GdUnitTools.prints_verbose("Register singleton '%s:%s'" % [name, singleton])
+ var singletons: PackedStringArray = Engine.get_meta(MEATA_KEY, PackedStringArray())
+ @warning_ignore("return_value_discarded")
+ singletons.append(name)
+ Engine.set_meta(MEATA_KEY, singletons)
+ return singleton
+
+
+static func unregister(p_singleton: String, use_call_deferred: bool = false) -> void:
+ var singletons: PackedStringArray = Engine.get_meta(MEATA_KEY, PackedStringArray())
+ if singletons.has(p_singleton):
+ GdUnitTools.prints_verbose("\n Unregister singleton '%s'" % p_singleton);
+ var index := singletons.find(p_singleton)
+ singletons.remove_at(index)
+ var instance_: Object = Engine.get_meta(p_singleton)
+ GdUnitTools.prints_verbose(" Free singleton instance '%s:%s'" % [p_singleton, instance_])
+ @warning_ignore("return_value_discarded")
+ GdUnitTools.free_instance(instance_, use_call_deferred)
+ Engine.remove_meta(p_singleton)
+ GdUnitTools.prints_verbose(" Successfully freed '%s'" % p_singleton)
+ Engine.set_meta(MEATA_KEY, singletons)
+
+
+static func dispose(use_call_deferred: bool = false) -> void:
+ # use a copy because unregister is modify the singletons array
+ var singletons: PackedStringArray = Engine.get_meta(MEATA_KEY, PackedStringArray())
+ GdUnitTools.prints_verbose("----------------------------------------------------------------")
+ GdUnitTools.prints_verbose("Cleanup singletons %s" % singletons)
+ for singleton in PackedStringArray(singletons):
+ unregister(singleton, use_call_deferred)
+ Engine.remove_meta(MEATA_KEY)
+ GdUnitTools.prints_verbose("----------------------------------------------------------------")
diff --git a/addons/gdUnit4/src/core/GdUnitSingleton.gd.uid b/addons/gdUnit4/src/core/GdUnitSingleton.gd.uid
new file mode 100644
index 0000000..d54a18d
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitSingleton.gd.uid
@@ -0,0 +1 @@
+uid://b5fwmspo4wq8k
diff --git a/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd
new file mode 100644
index 0000000..33b80e2
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd
@@ -0,0 +1,97 @@
+class_name GdUnitTestResourceLoader
+extends RefCounted
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+enum {
+ GD_SUITE,
+ CS_SUITE
+}
+
+
+static func load_test_suite(resource_path: String, script_type := GD_SUITE) -> Node:
+ match script_type:
+ GD_SUITE:
+ return load_test_suite_gd(resource_path)
+ CS_SUITE:
+ return load_test_suite_cs(resource_path)
+ assert("type '%s' is not implemented" % script_type)
+ return null
+
+
+static func load_tests(resource_path: String) -> Dictionary:
+ var script := load_gd_script(resource_path)
+ var discovered_tests := {}
+ GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void:
+ discovered_tests[test.display_name] = test
+ )
+
+ return discovered_tests
+
+
+static func load_test_suite_gd(resource_path: String) -> GdUnitTestSuite:
+ var script := load_gd_script(resource_path)
+ var discovered_tests: Array[GdUnitTestCase] = []
+ GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void:
+ discovered_tests.append(test)
+ )
+ # complete test suite wiht parsed test cases
+ return GdUnitTestSuiteScanner.new().load_suite(script, discovered_tests)
+
+
+static func load_test_suite_cs(resource_path: String) -> Node:
+ if not GdUnit4CSharpApiLoader.is_api_loaded():
+ return null
+ var script :Script = ClassDB.instantiate("CSharpScript")
+ script.source_code = GdUnitFileAccess.resource_as_string(resource_path)
+ script.resource_path = resource_path
+ script.reload()
+ return null
+
+
+static func load_cs_script(resource_path: String, debug_write := false) -> Script:
+ if not GdUnit4CSharpApiLoader.is_api_loaded():
+ return null
+ var script :Script = ClassDB.instantiate("CSharpScript")
+ script.source_code = GdUnitFileAccess.resource_as_string(resource_path)
+ var script_resource_path := resource_path.replace(resource_path.get_extension(), "cs")
+ if debug_write:
+ script_resource_path = GdUnitFileAccess.create_temp_dir("test") + "/%s" % script_resource_path.get_file()
+ print_debug("save resource:", script_resource_path)
+ DirAccess.remove_absolute(script_resource_path)
+ var err := ResourceSaver.save(script, script_resource_path)
+ if err != OK:
+ print_debug("Can't save debug resource",script_resource_path, "Error:", error_string(err))
+ script.take_over_path(script_resource_path)
+ else:
+ script.take_over_path(resource_path)
+ script.reload()
+ return script
+
+
+static func load_gd_script(resource_path: String, debug_write := false) -> GDScript:
+ # grap current level
+ var unsafe_method_access: Variant = ProjectSettings.get_setting("debug/gdscript/warnings/unsafe_method_access")
+ # disable and load the script
+ ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", 0)
+
+ var script := GDScript.new()
+ script.source_code = GdUnitFileAccess.resource_as_string(resource_path)
+ var script_resource_path := resource_path.replace(resource_path.get_extension(), "gd")
+ if debug_write:
+ script_resource_path = script_resource_path.replace("res://", GdUnitFileAccess.temp_dir() + "/")
+ #print_debug("save resource: ", script_resource_path)
+ DirAccess.remove_absolute(script_resource_path)
+ DirAccess.make_dir_recursive_absolute(script_resource_path.get_base_dir())
+ var err := ResourceSaver.save(script, script_resource_path, ResourceSaver.FLAG_REPLACE_SUBRESOURCE_PATHS)
+ if err != OK:
+ print_debug("Can't save debug resource", script_resource_path, "Error:", error_string(err))
+ script.take_over_path(script_resource_path)
+ else:
+ script.take_over_path(resource_path)
+ var error := script.reload()
+ if error != OK:
+ push_error("Errors on loading script %s. Error: %s" % [resource_path, error_string(error)])
+ ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", unsafe_method_access)
+ return script
+ #@warning_ignore("unsafe_cast")
diff --git a/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid
new file mode 100644
index 0000000..dbf6706
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTestResourceLoader.gd.uid
@@ -0,0 +1 @@
+uid://dth63om1kgmwc
diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd
new file mode 100644
index 0000000..97a49b0
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd
@@ -0,0 +1,20 @@
+class_name GdUnitTestSuiteBuilder
+extends RefCounted
+
+
+static func create(source :Script, line_number :int) -> GdUnitResult:
+ var test_suite_path := GdUnitTestSuiteScanner.resolve_test_suite_path(source.resource_path, GdUnitSettings.test_root_folder())
+ # we need to save and close the testsuite and source if is current opened before modify
+ @warning_ignore("return_value_discarded")
+ ScriptEditorControls.save_an_open_script(source.resource_path)
+ @warning_ignore("return_value_discarded")
+ ScriptEditorControls.save_an_open_script(test_suite_path, true)
+ if source.get_class() == "CSharpScript":
+ return GdUnit4CSharpApiLoader.create_test_suite(source.resource_path, line_number+1, test_suite_path)
+ var parser := GdScriptParser.new()
+ var lines := source.source_code.split("\n")
+ var current_line := lines[line_number]
+ var func_name := parser.parse_func_name(current_line)
+ if func_name.is_empty():
+ return GdUnitResult.error("No function found at line: %d." % line_number)
+ return GdUnitTestSuiteScanner.create_test_case(test_suite_path, func_name, source.resource_path)
diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid
new file mode 100644
index 0000000..7143f81
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTestSuiteBuilder.gd.uid
@@ -0,0 +1 @@
+uid://bxpdkva8owdce
diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd
new file mode 100644
index 0000000..345b7ed
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd
@@ -0,0 +1,410 @@
+class_name GdUnitTestSuiteScanner
+extends RefCounted
+
+const TEST_FUNC_TEMPLATE ="""
+
+func test_${func_name}() -> void:
+ # remove this line and complete your test
+ assert_not_yet_implemented()
+"""
+
+
+# we exclude the gdunit source directorys by default
+const exclude_scan_directories = [
+ "res://addons/gdUnit4/bin",
+ "res://addons/gdUnit4/src",
+ "res://reports"]
+
+
+const ARGUMENT_TIMEOUT := "timeout"
+const ARGUMENT_SKIP := "do_skip"
+const ARGUMENT_SKIP_REASON := "skip_reason"
+const ARGUMENT_PARAMETER_SET := "test_parameters"
+
+
+var _script_parser := GdScriptParser.new()
+var _included_resources: PackedStringArray = []
+var _excluded_resources: PackedStringArray = []
+var _expression_runner := GdUnitExpressionRunner.new()
+var _regex_extends_clazz_name := RegEx.create_from_string("extends[\\s]+([\\S]+)")
+
+
+func prescan_testsuite_classes() -> void:
+ # scan and cache extends GdUnitTestSuite by class name an resource paths
+ var script_classes: Array[Dictionary] = ProjectSettings.get_global_class_list()
+ for script_meta in script_classes:
+ var base_class: String = script_meta["base"]
+ var resource_path: String = script_meta["path"]
+ if base_class == "GdUnitTestSuite":
+ @warning_ignore("return_value_discarded")
+ _included_resources.append(resource_path)
+ elif ClassDB.class_exists(base_class):
+ @warning_ignore("return_value_discarded")
+ _excluded_resources.append(resource_path)
+
+
+func scan(resource_path: String) -> Array[Script]:
+ prescan_testsuite_classes()
+ # if single testsuite requested
+ if FileAccess.file_exists(resource_path):
+ var test_suite := _load_is_test_suite(resource_path)
+ if test_suite != null:
+ return [test_suite]
+ return []
+ return scan_directory(resource_path)
+
+
+func scan_directory(resource_path: String) -> Array[Script]:
+ prescan_testsuite_classes()
+ # We use the global cache to fast scan for test suites.
+ if _excluded_resources.has(resource_path):
+ return []
+
+ var base_dir := DirAccess.open(resource_path)
+ if base_dir == null:
+ prints("Given directory or file does not exists:", resource_path)
+ return []
+
+ prints("Scanning for test suites in:", resource_path)
+ return _scan_test_suites_scripts(base_dir, [])
+
+
+func _scan_test_suites_scripts(dir: DirAccess, collected_suites: Array[Script]) -> Array[Script]:
+ # Skip excluded directories
+ if dir.file_exists(".gdignore"):
+ prints("Exclude directory %s, containing .gdignore file" % dir.get_current_dir())
+ return []
+
+ if exclude_scan_directories.has(dir.get_current_dir()):
+ return collected_suites
+
+ var err := dir.list_dir_begin()
+ if err != OK:
+ push_error("Error on scanning directory %s" % dir.get_current_dir(), error_string(err))
+ return collected_suites
+ var file_name := dir.get_next()
+ while file_name != "":
+ var resource_path := GdUnitTestSuiteScanner._file(dir, file_name)
+ if dir.current_is_dir():
+ var sub_dir := DirAccess.open(resource_path)
+ if sub_dir != null:
+ @warning_ignore("return_value_discarded")
+ _scan_test_suites_scripts(sub_dir, collected_suites)
+ else:
+ var time := LocalTime.now()
+ var test_suite := _load_is_test_suite(resource_path)
+ if test_suite:
+ collected_suites.append(test_suite)
+ if OS.is_stdout_verbose() and time.elapsed_since_ms() > 300:
+ push_warning("Scanning of test-suite '%s' took more than 300ms: " % resource_path, time.elapsed_since())
+ file_name = dir.get_next()
+ return collected_suites
+
+
+static func _file(dir: DirAccess, file_name: String) -> String:
+ var current_dir := dir.get_current_dir()
+ if current_dir.ends_with("/"):
+ return current_dir + file_name
+ return current_dir + "/" + file_name
+
+
+func _load_is_test_suite(resource_path: String) -> Script:
+ if not GdUnitTestSuiteScanner._is_script_format_supported(resource_path):
+ return null
+
+ # We use the global cache to fast scan for test suites.
+ if _excluded_resources.has(resource_path):
+ return null
+ # Check in the global class cache whether the GdUnitTestSuite class has been extended.
+ if _included_resources.has(resource_path):
+ return GdUnitTestSuiteScanner.load_with_disabled_warnings(resource_path)
+
+ # Otherwise we need to scan manual, we need to exclude classes where direct extends form Godot classes
+ # the resource loader can fail to load e.g. plugin classes with do preload other scripts
+ #var extends_from := get_extends_classname(resource_path)
+ # If not extends is defined or extends from a Godot class
+ #if extends_from.is_empty() or ClassDB.class_exists(extends_from):
+ # return null
+ # Finally, we need to load the class to determine it is a test suite
+ var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(resource_path)
+ if not is_test_suite(script):
+ return null
+ return script
+
+
+func load_suite(script: GDScript, tests: Array[GdUnitTestCase]) -> GdUnitTestSuite:
+ var test_suite: GdUnitTestSuite = script.new()
+ var first_test: GdUnitTestCase = tests.front()
+ test_suite.set_name(first_test.suite_name)
+
+ # We need to group first all parameterized tests together to load the parameter set once
+ var grouped_by_test := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String:
+ return test.test_name
+ )
+ # Extract function descriptors
+ var test_names: PackedStringArray = grouped_by_test.keys()
+ test_names.append("before")
+ var function_descriptors := _script_parser.get_function_descriptors(script, test_names)
+
+ # Convert to test
+ for fd in function_descriptors:
+ if fd.name() == "before":
+ _handle_test_suite_arguments(test_suite, script, fd)
+ continue
+
+ # Build test attributes from test method
+ var test_attribute := _build_test_attribute(script, fd)
+ # Create test from descriptor and given attributes
+ var test_group: Array = grouped_by_test[fd.name()]
+ for test: GdUnitTestCase in test_group:
+ # We need a copy, because of mutable state
+ var attribute: TestCaseAttribute = test_attribute.clone()
+ test_suite.add_child(_TestCase.new(test, attribute, fd))
+ return test_suite
+
+
+func _build_test_attribute(script: GDScript, fd: GdFunctionDescriptor) -> TestCaseAttribute:
+ var collected_unknown_aruments := PackedStringArray()
+ var attribute := TestCaseAttribute.new()
+
+ # Collect test attributes
+ for arg: GdFunctionArgument in fd.args():
+ if arg.type() == GdObjects.TYPE_FUZZER:
+ attribute.fuzzers.append(arg)
+ else:
+ # We allow underscore as prefix to prevent unused argument warnings
+ match arg.name().trim_prefix("_"):
+ ARGUMENT_TIMEOUT:
+ attribute.timeout = type_convert(arg.default(), TYPE_INT)
+ ARGUMENT_SKIP:
+ var result: Variant = _expression_runner.execute(script, arg.plain_value())
+ if result is bool:
+ attribute.is_skipped = result
+ else:
+ push_error("Test expression '%s' cannot be evaluated because it is not of type bool!" % arg.plain_value())
+ ARGUMENT_SKIP_REASON:
+ attribute.skip_reason = arg.plain_value()
+ Fuzzer.ARGUMENT_ITERATIONS:
+ attribute.fuzzer_iterations = type_convert(arg.default(), TYPE_INT)
+ Fuzzer.ARGUMENT_SEED:
+ attribute.test_seed = type_convert(arg.default(), TYPE_INT)
+ ARGUMENT_PARAMETER_SET:
+ collected_unknown_aruments.clear()
+ pass
+ _:
+ collected_unknown_aruments.append(arg.name())
+
+ # Verify for unknown arguments
+ if not collected_unknown_aruments.is_empty():
+ attribute.is_skipped = true
+ attribute.skip_reason = "Unknown test case argument's %s found." % collected_unknown_aruments
+
+ return attribute
+
+
+# We load the test suites with disabled unsafe_method_access to avoid spamming loading errors
+# `unsafe_method_access` will happen when using `assert_that`
+static func load_with_disabled_warnings(resource_path: String) -> Script:
+ # grap current level
+ var unsafe_method_access: Variant = ProjectSettings.get_setting("debug/gdscript/warnings/unsafe_method_access")
+
+ # disable and load the script
+ ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", 0)
+
+ var script: Script = (
+ GdUnitTestResourceLoader.load_gd_script(resource_path) if resource_path.ends_with("resource")
+ else ResourceLoader.load(resource_path))
+
+ # restore
+ ProjectSettings.set_setting("debug/gdscript/warnings/unsafe_method_access", unsafe_method_access)
+ return script
+
+
+static func is_test_suite(script: Script) -> bool:
+ if script is GDScript:
+ var stack := [script]
+ while not stack.is_empty():
+ var current: Script = stack.pop_front()
+ var base: Script = current.get_base_script()
+ if base != null:
+ if base.resource_path.find("GdUnitTestSuite") != -1:
+ return true
+ stack.push_back(base)
+ elif script != null and script.get_class() == "CSharpScript":
+ return true
+ return false
+
+
+static func _is_script_format_supported(resource_path: String) -> bool:
+ var ext := resource_path.get_extension()
+ return ext == "gd" or ext == "cs"
+
+
+static func parse_test_suite_name(script: Script) -> String:
+ return script.resource_path.get_file().replace(".gd", "")
+
+
+func _handle_test_suite_arguments(test_suite: GdUnitTestSuite, script: GDScript, fd: GdFunctionDescriptor) -> void:
+ for arg in fd.args():
+ # We allow underscore as prefix to prevent unused argument warnings
+ match arg.name().trim_prefix("_"):
+ ARGUMENT_SKIP:
+ var result: Variant = _expression_runner.execute(script, arg.plain_value())
+ if result is bool:
+ test_suite.__is_skipped = result
+ else:
+ push_error("Test expression '%s' cannot be evaluated because it is not of type bool!" % arg.plain_value())
+ ARGUMENT_SKIP_REASON:
+ test_suite.__skip_reason = arg.plain_value()
+ _:
+ push_error("Unsuported argument `%s` found on before() at '%s'!" % [arg.name(), script.resource_path])
+
+
+# converts given file name by configured naming convention
+static func _to_naming_convention(file_name: String) -> String:
+ var nc :int = GdUnitSettings.get_setting(GdUnitSettings.TEST_SUITE_NAMING_CONVENTION, 0)
+ match nc:
+ GdUnitSettings.NAMING_CONVENTIONS.AUTO_DETECT:
+ if GdObjects.is_snake_case(file_name):
+ return GdObjects.to_snake_case(file_name + "Test")
+ return GdObjects.to_pascal_case(file_name + "Test")
+ GdUnitSettings.NAMING_CONVENTIONS.SNAKE_CASE:
+ return GdObjects.to_snake_case(file_name + "Test")
+ GdUnitSettings.NAMING_CONVENTIONS.PASCAL_CASE:
+ return GdObjects.to_pascal_case(file_name + "Test")
+ push_error("Unexpected case")
+ return "-<Unexpected>-"
+
+
+static func resolve_test_suite_path(source_script_path: String, test_root_folder: String = "test") -> String:
+ var file_name := source_script_path.get_basename().get_file()
+ var suite_name := _to_naming_convention(file_name)
+ if test_root_folder.is_empty() or test_root_folder == "/":
+ return source_script_path.replace(file_name, suite_name)
+
+ # is user tmp
+ if source_script_path.begins_with("user://tmp"):
+ return normalize_path(source_script_path.replace("user://tmp", "user://tmp/" + test_root_folder)).replace(file_name, suite_name)
+
+ # at first look up is the script under a "src" folder located
+ var test_suite_path: String
+ var src_folder := source_script_path.find("/src/")
+ if src_folder != -1:
+ test_suite_path = source_script_path.replace("/src/", "/"+test_root_folder+"/")
+ else:
+ var paths := source_script_path.split("/", false)
+ # is a plugin script?
+ if paths[1] == "addons":
+ test_suite_path = "%s//addons/%s/%s" % [paths[0], paths[2], test_root_folder]
+ # rebuild plugin path
+ for index in range(3, paths.size()):
+ test_suite_path += "/" + paths[index]
+ else:
+ test_suite_path = paths[0] + "//" + test_root_folder
+ for index in range(1, paths.size()):
+ test_suite_path += "/" + paths[index]
+ return normalize_path(test_suite_path).replace(file_name, suite_name)
+
+
+static func normalize_path(path: String) -> String:
+ return path.replace("///", "/")
+
+
+static func create_test_suite(test_suite_path: String, source_path: String) -> GdUnitResult:
+ # create directory if not exists
+ if not DirAccess.dir_exists_absolute(test_suite_path.get_base_dir()):
+ var error_ := DirAccess.make_dir_recursive_absolute(test_suite_path.get_base_dir())
+ if error_ != OK:
+ return GdUnitResult.error("Can't create directoy at: %s. Error code %s" % [test_suite_path.get_base_dir(), error_])
+ var script := GDScript.new()
+ script.source_code = GdUnitTestSuiteTemplate.build_template(source_path)
+ var error := ResourceSaver.save(script, test_suite_path)
+ if error != OK:
+ return GdUnitResult.error("Can't create test suite at: %s. Error code %s" % [test_suite_path, error])
+ return GdUnitResult.success(test_suite_path)
+
+
+static func get_test_case_line_number(resource_path: String, func_name: String) -> int:
+ var file := FileAccess.open(resource_path, FileAccess.READ)
+ if file != null:
+ var line_number := 0
+ while not file.eof_reached():
+ var row := file.get_line()
+ line_number += 1
+ # ignore comments and empty lines and not test functions
+ if row.begins_with("#") || row.length() == 0 || row.find("func test_") == -1:
+ continue
+ # abort if test case name found
+ if row.find("func") != -1 and row.find("test_" + func_name) != -1:
+ return line_number
+ return -1
+
+
+func get_extends_classname(resource_path: String) -> String:
+ var file := FileAccess.open(resource_path, FileAccess.READ)
+ if file != null:
+ while not file.eof_reached():
+ var row := file.get_line()
+ # skip comments and empty lines
+ if row.begins_with("#") || row.length() == 0:
+ continue
+ # Stop at first function
+ if row.contains("func"):
+ return ""
+ var result := _regex_extends_clazz_name.search(row)
+ if result != null:
+ return result.get_string(1)
+ return ""
+
+
+static func add_test_case(resource_path: String, func_name: String) -> GdUnitResult:
+ var script := load_with_disabled_warnings(resource_path)
+ # count all exiting lines and add two as space to add new test case
+ var line_number := count_lines(script) + 2
+ var func_body := TEST_FUNC_TEMPLATE.replace("${func_name}", func_name)
+ if Engine.is_editor_hint():
+ # NOTE: Avoid using EditorInterface and EditorSettings directly,
+ # as it causes compilation errors in exported projects.
+ @warning_ignore_start("unsafe_method_access")
+ var editor_interface: Object = Engine.get_singleton("EditorInterface")
+ var settings: Object = editor_interface.get_editor_settings()
+ var ident_type: int = settings.get_setting("text_editor/behavior/indent/type")
+ var ident_size: int = settings.get_setting("text_editor/behavior/indent/size")
+ @warning_ignore_restore("unsafe_method_access")
+ if ident_type == 1:
+ func_body = func_body.replace(" ", "".lpad(ident_size, " "))
+ script.source_code += func_body
+ var error := ResourceSaver.save(script, resource_path)
+ if error != OK:
+ return GdUnitResult.error("Can't add test case at: %s to '%s'. Error code %s" % [func_name, resource_path, error])
+ return GdUnitResult.success({ "path" : resource_path, "line" : line_number})
+
+
+static func count_lines(script: Script) -> int:
+ return script.source_code.split("\n").size()
+
+
+static func test_suite_exists(test_suite_path: String) -> bool:
+ return FileAccess.file_exists(test_suite_path)
+
+
+static func test_case_exists(test_suite_path :String, func_name :String) -> bool:
+ if not test_suite_exists(test_suite_path):
+ return false
+ var script := load_with_disabled_warnings(test_suite_path)
+ for f in script.get_script_method_list():
+ if f["name"] == "test_" + func_name:
+ return true
+ return false
+
+
+static func create_test_case(test_suite_path: String, func_name: String, source_script_path: String) -> GdUnitResult:
+ if test_case_exists(test_suite_path, func_name):
+ var line_number := get_test_case_line_number(test_suite_path, func_name)
+ return GdUnitResult.success({ "path" : test_suite_path, "line" : line_number})
+
+ if not test_suite_exists(test_suite_path):
+ var result := create_test_suite(test_suite_path, source_script_path)
+ if result.is_error():
+ return result
+ return add_test_case(test_suite_path, func_name)
diff --git a/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid
new file mode 100644
index 0000000..223b357
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTestSuiteScanner.gd.uid
@@ -0,0 +1 @@
+uid://crsmt5amypl65
diff --git a/addons/gdUnit4/src/core/GdUnitTools.gd b/addons/gdUnit4/src/core/GdUnitTools.gd
new file mode 100644
index 0000000..0742896
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTools.gd
@@ -0,0 +1,144 @@
+extends RefCounted
+
+
+static var _richtext_normalize: RegEx
+
+
+static func normalize_text(text :String) -> String:
+ return text.replace("\r", "");
+
+
+static func richtext_normalize(input :String) -> String:
+ if _richtext_normalize == null:
+ _richtext_normalize = to_regex("\\[/?(b|color|bgcolor|right|table|cell).*?\\]")
+ return _richtext_normalize.sub(input, "", true).replace("\r", "")
+
+
+static func to_regex(pattern :String) -> RegEx:
+ var regex := RegEx.new()
+ var err := regex.compile(pattern)
+ if err != OK:
+ push_error("Can't compiling regx '%s'.\n ERROR: %s" % [pattern, error_string(err)])
+ return regex
+
+
+static func prints_verbose(message :String) -> void:
+ if OS.is_stdout_verbose():
+ prints(message)
+
+
+static func free_instance(instance :Variant, use_call_deferred :bool = false, is_stdout_verbose := false) -> bool:
+ if instance is Array:
+ var as_array: Array = instance
+ for element: Variant in as_array:
+ @warning_ignore("return_value_discarded")
+ free_instance(element)
+ as_array.clear()
+ return true
+ # do not free an already freed instance
+ if not is_instance_valid(instance):
+ return false
+ # do not free a class refernece
+ @warning_ignore("unsafe_cast")
+ if typeof(instance) == TYPE_OBJECT and (instance as Object).is_class("GDScriptNativeClass"):
+ return false
+ if is_stdout_verbose:
+ print_verbose("GdUnit4:gc():free instance ", instance)
+ @warning_ignore("unsafe_cast")
+ release_double(instance as Object)
+ if instance is RefCounted:
+ @warning_ignore("unsafe_cast")
+ (instance as RefCounted).notification(Object.NOTIFICATION_PREDELETE)
+ # If scene runner freed we explicit await all inputs are processed
+ if instance is GdUnitSceneRunnerImpl:
+ @warning_ignore("unsafe_cast")
+ await (instance as GdUnitSceneRunnerImpl).await_input_processed()
+ return true
+ else:
+ if instance is Timer:
+ var timer: Timer = instance
+ timer.stop()
+ if use_call_deferred:
+ timer.call_deferred("free")
+ else:
+ timer.free()
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ return true
+
+ @warning_ignore("unsafe_cast")
+ if instance is Node and (instance as Node).get_parent() != null:
+ var node: Node = instance
+ if is_stdout_verbose:
+ print_verbose("GdUnit4:gc():remove node from parent ", node.get_parent(), node)
+ if use_call_deferred:
+ node.get_parent().remove_child.call_deferred(node)
+ #instance.call_deferred("set_owner", null)
+ else:
+ node.get_parent().remove_child(node)
+ if is_stdout_verbose:
+ print_verbose("GdUnit4:gc():freeing `free()` the instance ", instance)
+ if use_call_deferred:
+ @warning_ignore("unsafe_cast")
+ (instance as Object).call_deferred("free")
+ else:
+ @warning_ignore("unsafe_cast")
+ (instance as Object).free()
+ return !is_instance_valid(instance)
+
+
+static func _release_connections(instance :Object) -> void:
+ if is_instance_valid(instance):
+ # disconnect from all connected signals to force freeing, otherwise it ends up in orphans
+ for connection in instance.get_incoming_connections():
+ var signal_ :Signal = connection["signal"]
+ var callable_ :Callable = connection["callable"]
+ #prints(instance, connection)
+ #prints("signal", signal_.get_name(), signal_.get_object())
+ #prints("callable", callable_.get_object())
+ if instance.has_signal(signal_.get_name()) and instance.is_connected(signal_.get_name(), callable_):
+ #prints("disconnect signal", signal_.get_name(), callable_)
+ instance.disconnect(signal_.get_name(), callable_)
+ release_timers()
+
+
+static func release_timers() -> void:
+ # we go the new way to hold all gdunit timers in group 'GdUnitTimers'
+ var scene_tree := Engine.get_main_loop() as SceneTree
+ if scene_tree.root == null:
+ return
+ for node :Node in scene_tree.root.get_children():
+ if is_instance_valid(node) and node.is_in_group("GdUnitTimers"):
+ if is_instance_valid(node):
+ scene_tree.root.remove_child.call_deferred(node)
+ (node as Timer).stop()
+ node.queue_free()
+
+
+# the finally cleaup unfreed resources and singletons
+static func dispose_all(use_call_deferred :bool = false) -> void:
+ release_timers()
+ GdUnitSingleton.dispose(use_call_deferred)
+ GdUnitSignals.dispose()
+
+
+# if instance an mock or spy we need manually freeing the self reference
+static func release_double(instance :Object) -> void:
+ if instance.has_method("__release_double"):
+ instance.call("__release_double")
+
+
+
+static func find_test_case(test_suite: Node, test_case_name: String, index := -1) -> _TestCase:
+ for test_case: _TestCase in test_suite.get_children():
+ if test_case.test_name() == test_case_name:
+ if index != -1:
+ if test_case._test_case.attribute_index != index:
+ continue
+ return test_case
+ return null
+
+
+static func register_expect_interupted_by_timeout(test_suite: Node, test_case_name: String) -> void:
+ var test_case := find_test_case(test_suite, test_case_name)
+ if test_case:
+ test_case.expect_to_interupt()
diff --git a/addons/gdUnit4/src/core/GdUnitTools.gd.uid b/addons/gdUnit4/src/core/GdUnitTools.gd.uid
new file mode 100644
index 0000000..af02f0d
--- /dev/null
+++ b/addons/gdUnit4/src/core/GdUnitTools.gd.uid
@@ -0,0 +1 @@
+uid://wmir42v6nqmf
diff --git a/addons/gdUnit4/src/core/GodotVersionFixures.gd b/addons/gdUnit4/src/core/GodotVersionFixures.gd
new file mode 100644
index 0000000..5299325
--- /dev/null
+++ b/addons/gdUnit4/src/core/GodotVersionFixures.gd
@@ -0,0 +1,11 @@
+## This service class contains helpers to wrap Godot functions and handle them carefully depending on the current Godot version
+class_name GodotVersionFixures
+extends RefCounted
+
+
+# handle global_position fixed by https://github.com/godotengine/godot/pull/88473
+static func set_event_global_position(event: InputEventMouseMotion, global_position: Vector2) -> void:
+ if Engine.get_version_info().hex >= 0x40202 or Engine.get_version_info().hex == 0x40104:
+ event.global_position = event.position
+ else:
+ event.global_position = global_position
diff --git a/addons/gdUnit4/src/core/GodotVersionFixures.gd.uid b/addons/gdUnit4/src/core/GodotVersionFixures.gd.uid
new file mode 100644
index 0000000..5b874df
--- /dev/null
+++ b/addons/gdUnit4/src/core/GodotVersionFixures.gd.uid
@@ -0,0 +1 @@
+uid://b0kdldwgufklw
diff --git a/addons/gdUnit4/src/core/LocalTime.gd b/addons/gdUnit4/src/core/LocalTime.gd
new file mode 100644
index 0000000..fabaaf6
--- /dev/null
+++ b/addons/gdUnit4/src/core/LocalTime.gd
@@ -0,0 +1,114 @@
+# This class provides Date/Time functionallity to Godot
+class_name LocalTime
+extends Resource
+
+enum TimeUnit {
+ DEFAULT = 0,
+ MILLIS = 1,
+ SECOND = 2,
+ MINUTE = 3,
+ HOUR = 4,
+ DAY = 5,
+ MONTH = 6,
+ YEAR = 7
+}
+
+const SECONDS_PER_MINUTE:int = 60
+const MINUTES_PER_HOUR:int = 60
+const HOURS_PER_DAY:int = 24
+const MILLIS_PER_SECOND:int = 1000
+const MILLIS_PER_MINUTE:int = MILLIS_PER_SECOND * SECONDS_PER_MINUTE
+const MILLIS_PER_HOUR:int = MILLIS_PER_MINUTE * MINUTES_PER_HOUR
+
+var _time :int
+var _hour :int
+var _minute :int
+var _second :int
+var _millisecond :int
+
+
+static func now() -> LocalTime:
+ return LocalTime.new(_get_system_time_msecs())
+
+
+static func of_unix_time(time_ms :int) -> LocalTime:
+ return LocalTime.new(time_ms)
+
+
+static func local_time(hours :int, minutes :int, seconds :int, milliseconds :int) -> LocalTime:
+ return LocalTime.new(MILLIS_PER_HOUR * hours\
+ + MILLIS_PER_MINUTE * minutes\
+ + MILLIS_PER_SECOND * seconds\
+ + milliseconds)
+
+
+func elapsed_since() -> String:
+ return LocalTime.elapsed(LocalTime._get_system_time_msecs() - _time)
+
+
+func elapsed_since_ms() -> int:
+ return LocalTime._get_system_time_msecs() - _time
+
+
+func plus(time_unit :TimeUnit, value :int) -> LocalTime:
+ var addValue:int = 0
+ match time_unit:
+ TimeUnit.MILLIS:
+ addValue = value
+ TimeUnit.SECOND:
+ addValue = value * MILLIS_PER_SECOND
+ TimeUnit.MINUTE:
+ addValue = value * MILLIS_PER_MINUTE
+ TimeUnit.HOUR:
+ addValue = value * MILLIS_PER_HOUR
+ @warning_ignore("return_value_discarded")
+ _init(_time + addValue)
+ return self
+
+
+static func elapsed(p_time_ms :int) -> String:
+ var local_time_ := LocalTime.new(p_time_ms)
+ if local_time_._hour > 0:
+ return "%dh %dmin %ds %dms" % [local_time_._hour, local_time_._minute, local_time_._second, local_time_._millisecond]
+ if local_time_._minute > 0:
+ return "%dmin %ds %dms" % [local_time_._minute, local_time_._second, local_time_._millisecond]
+ if local_time_._second > 0:
+ return "%ds %dms" % [local_time_._second, local_time_._millisecond]
+ return "%dms" % local_time_._millisecond
+
+
+# create from epoch timestamp in ms
+func _init(time: int) -> void:
+ _time = time
+ @warning_ignore("integer_division")
+ _hour = (time / MILLIS_PER_HOUR) % 24
+ @warning_ignore("integer_division")
+ _minute = (time / MILLIS_PER_MINUTE) % 60
+ @warning_ignore("integer_division")
+ _second = (time / MILLIS_PER_SECOND) % 60
+ _millisecond = time % 1000
+
+
+func hour() -> int:
+ return _hour
+
+
+func minute() -> int:
+ return _minute
+
+
+func second() -> int:
+ return _second
+
+
+func millis() -> int:
+ return _millisecond
+
+
+func _to_string() -> String:
+ return "%02d:%02d:%02d.%03d" % [_hour, _minute, _second, _millisecond]
+
+
+# wraper to old OS.get_system_time_msecs() function
+static func _get_system_time_msecs() -> int:
+ return Time.get_unix_time_from_system() * 1000 as int
diff --git a/addons/gdUnit4/src/core/LocalTime.gd.uid b/addons/gdUnit4/src/core/LocalTime.gd.uid
new file mode 100644
index 0000000..ef275de
--- /dev/null
+++ b/addons/gdUnit4/src/core/LocalTime.gd.uid
@@ -0,0 +1 @@
+uid://dq6srj6me3mjo
diff --git a/addons/gdUnit4/src/core/_TestCase.gd b/addons/gdUnit4/src/core/_TestCase.gd
new file mode 100644
index 0000000..2ef07dc
--- /dev/null
+++ b/addons/gdUnit4/src/core/_TestCase.gd
@@ -0,0 +1,253 @@
+class_name _TestCase
+extends Node
+
+signal completed()
+
+
+var _test_case: GdUnitTestCase
+var _attribute: TestCaseAttribute
+var _current_iteration: int = -1
+var _expect_to_interupt := false
+var _timer: Timer
+var _interupted := false
+var _terminated := false
+var _failed := false
+var _parameter_set_resolver: GdUnitTestParameterSetResolver
+var _is_disposed := false
+var _func_state: Variant
+
+
+func _init(test_case: GdUnitTestCase, attribute: TestCaseAttribute, fd: GdFunctionDescriptor) -> void:
+ _test_case = test_case
+ _attribute = attribute
+ set_function_descriptor(fd)
+
+
+func execute(p_test_parameter := Array(), p_iteration := 0) -> void:
+ _failure_received(false)
+ _current_iteration = p_iteration - 1
+ if _current_iteration == - 1:
+ _set_failure_handler()
+ set_timeout()
+
+ if is_parameterized():
+ execute_parameterized()
+ elif not p_test_parameter.is_empty():
+ update_fuzzers(p_test_parameter, p_iteration)
+ _execute_test_case(test_name(), p_test_parameter)
+ else:
+ _execute_test_case(test_name(), [])
+ await completed
+
+
+func execute_parameterized() -> void:
+ _failure_received(false)
+ set_timeout()
+
+ # Resolve parameter set at runtime to include runtime variables
+ var test_parameters := await _resolve_test_parameters(_test_case.attribute_index)
+ if test_parameters.is_empty():
+ return
+
+ await _execute_test_case(test_name(), test_parameters)
+
+
+func _resolve_test_parameters(attribute_index: int) -> Array:
+ var result := _parameter_set_resolver.load_parameter_sets(get_parent())
+ if result.is_error():
+ do_skip(true, result.error_message())
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ completed.emit()
+ return []
+
+ # validate the parameter set
+ var parameter_sets: Array = result.value()
+ result = _parameter_set_resolver.validate(parameter_sets, attribute_index)
+ if result.is_error():
+ do_skip(true, result.error_message())
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ completed.emit()
+ return []
+
+ @warning_ignore("unsafe_method_access")
+ var test_parameters: Array = parameter_sets[attribute_index].duplicate()
+ # We need here to add a empty array to override the `test_parameters` to prevent initial "default" parameters from being used.
+ # This prevents objects in the argument list from being unnecessarily re-instantiated.
+ test_parameters.append([])
+
+ return test_parameters
+
+
+func dispose() -> void:
+ if _is_disposed:
+ return
+ _is_disposed = true
+ Engine.remove_meta("GD_TEST_FAILURE")
+ stop_timer()
+ _remove_failure_handler()
+ _attribute.fuzzers.clear()
+
+
+@warning_ignore("shadowed_variable_base_class", "redundant_await")
+func _execute_test_case(name: String, test_parameter: Array) -> void:
+ # save the function state like GDScriptFunctionState to dispose at test timeout to prevent orphan state
+ _func_state = get_parent().callv(name, test_parameter)
+ await _func_state
+ # needs at least on await otherwise it breaks the awaiting chain
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ completed.emit()
+
+
+func update_fuzzers(input_values: Array, iteration: int) -> void:
+ for fuzzer :Variant in input_values:
+ if fuzzer is Fuzzer:
+ fuzzer._iteration_index = iteration + 1
+
+
+func set_timeout() -> void:
+ if is_instance_valid(_timer):
+ return
+ var time: float = _attribute.timeout / 1000.0
+ _timer = Timer.new()
+ add_child(_timer)
+ _timer.set_name("gdunit_test_case_timer_%d" % _timer.get_instance_id())
+ @warning_ignore("return_value_discarded")
+ _timer.timeout.connect(do_interrupt, CONNECT_DEFERRED)
+ _timer.set_one_shot(true)
+ _timer.set_wait_time(time)
+ _timer.set_autostart(false)
+ _timer.start()
+
+
+func do_interrupt() -> void:
+ _interupted = true
+ # We need to dispose manually the function state here
+ GdObjects.dispose_function_state(_func_state)
+ if not is_expect_interupted():
+ var execution_context := GdUnitThreadManager.get_current_context().get_execution_context()
+ if is_fuzzed():
+ execution_context.add_report(GdUnitReport.new()\
+ .create(GdUnitReport.INTERUPTED, line_number(), GdAssertMessages.fuzzer_interuped(_current_iteration, "timedout")))
+ else:
+ execution_context.add_report(GdUnitReport.new()\
+ .create(GdUnitReport.INTERUPTED, line_number(), GdAssertMessages.test_timeout(_attribute.timeout)))
+ completed.emit()
+
+
+func do_terminate() -> void:
+ _terminated = true
+ # We need to dispose manually the function state here
+ GdObjects.dispose_function_state(_func_state)
+ var execution_context := GdUnitThreadManager.get_current_context().get_execution_context()
+ execution_context.add_report(GdUnitReport.new()\
+ .create(GdUnitReport.TERMINATED, line_number(), GdAssertMessages.test_session_terminated()))
+ completed.emit()
+
+
+func _set_failure_handler() -> void:
+ if not GdUnitSignals.instance().gdunit_set_test_failed.is_connected(_failure_received):
+ @warning_ignore("return_value_discarded")
+ GdUnitSignals.instance().gdunit_set_test_failed.connect(_failure_received)
+
+
+func _remove_failure_handler() -> void:
+ if GdUnitSignals.instance().gdunit_set_test_failed.is_connected(_failure_received):
+ GdUnitSignals.instance().gdunit_set_test_failed.disconnect(_failure_received)
+
+
+func _failure_received(is_failed: bool) -> void:
+ # is already failed?
+ if _failed:
+ return
+ _failed = is_failed
+ Engine.set_meta("GD_TEST_FAILURE", is_failed)
+
+
+func stop_timer() -> void:
+ # finish outstanding timeouts
+ if is_instance_valid(_timer):
+ _timer.stop()
+ _timer.call_deferred("free")
+ _timer = null
+
+
+func expect_to_interupt() -> void:
+ _expect_to_interupt = true
+
+
+func is_interupted() -> bool:
+ return _interupted
+
+
+func is_expect_interupted() -> bool:
+ return _expect_to_interupt
+
+
+func is_terminated() -> bool:
+ return _terminated
+
+
+func is_parameterized() -> bool:
+ return _parameter_set_resolver.is_parameterized()
+
+
+func is_skipped() -> bool:
+ return _attribute.is_skipped
+
+
+func skip_info() -> String:
+ return _attribute.skip_reason
+
+
+func id() -> GdUnitGUID:
+ return _test_case.guid
+
+
+func test_name() -> String:
+ return _test_case.test_name
+
+
+func line_number() -> int:
+ return _test_case.line_number
+
+
+func iterations() -> int:
+ return _attribute.fuzzer_iterations
+
+
+func seed_value() -> int:
+ return _attribute.test_seed
+
+
+func is_fuzzed() -> bool:
+ return not _attribute.fuzzers.is_empty()
+
+
+func fuzzer_arguments() -> Array[GdFunctionArgument]:
+ return _attribute.fuzzers
+
+
+func script_path() -> String:
+ return _test_case.source_file
+
+
+func ResourcePath() -> String:
+ return _test_case.source_file
+
+
+func generate_seed() -> void:
+ if _attribute.test_seed != -1:
+ seed(_attribute.test_seed)
+
+
+func do_skip(skipped: bool, reason: String="") -> void:
+ _attribute.is_skipped = skipped
+ _attribute.skip_reason = reason
+
+
+func set_function_descriptor(fd: GdFunctionDescriptor) -> void:
+ _parameter_set_resolver = GdUnitTestParameterSetResolver.new(fd)
+
+
+func _to_string() -> String:
+ return "%s :%d (%dms)" % [get_name(), _test_case.line_number, _attribute.timeout]
diff --git a/addons/gdUnit4/src/core/_TestCase.gd.uid b/addons/gdUnit4/src/core/_TestCase.gd.uid
new file mode 100644
index 0000000..5c7e8b9
--- /dev/null
+++ b/addons/gdUnit4/src/core/_TestCase.gd.uid
@@ -0,0 +1 @@
+uid://byae5d27sl1jq
diff --git a/addons/gdUnit4/src/core/assets/touch-button.png b/addons/gdUnit4/src/core/assets/touch-button.png
new file mode 100644
index 0000000..23f46ef
--- /dev/null
+++ b/addons/gdUnit4/src/core/assets/touch-button.png
Binary files differ
diff --git a/addons/gdUnit4/src/core/assets/touch-button.png.import b/addons/gdUnit4/src/core/assets/touch-button.png.import
new file mode 100644
index 0000000..4122b48
--- /dev/null
+++ b/addons/gdUnit4/src/core/assets/touch-button.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dhtfmfhdtq4qr"
+path="res://.godot/imported/touch-button.png-2fff40c8520d8e97a57db1b2b043f641.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/core/assets/touch-button.png"
+dest_files=["res://.godot/imported/touch-button.png-2fff40c8520d8e97a57db1b2b043f641.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd
new file mode 100644
index 0000000..cf7a2b9
--- /dev/null
+++ b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd
@@ -0,0 +1,76 @@
+class_name TestCaseAttribute
+extends Resource
+## Holds configuration and metadata for individual test cases.[br]
+## [br]
+## This class defines test behaviors and properties such as:[br]
+## - Test timeouts[br]
+## - Skip conditions[br]
+## - Fuzzing parameters[br]
+## - Random seed values[br]
+
+
+## When set, no specific timeout value is configured and test will use the [code]test_timeout[/code][br]
+## value from [GdUnitSettings].
+const DEFAULT_TIMEOUT := -1
+
+
+## The maximum time in milliseconds for test completion.[br]
+## The test fails if execution exceeds this duration.[br]
+## [br]
+## When set to [constant DEFAULT_TIMEOUT], uses the value from [method GdUnitSettings.test_timeout].
+var timeout: int = DEFAULT_TIMEOUT:
+ set(value):
+ timeout = value
+ get:
+ if timeout == DEFAULT_TIMEOUT:
+ # get the default timeout from the settings
+ timeout = GdUnitSettings.test_timeout()
+ return timeout
+
+
+## The seed used for random number generation in the test.[br]
+## Ensures reproducible results for randomized test scenarios.[br]
+## A value of -1 indicates no specific seed is set.
+var test_seed: int = -1
+
+
+## Controls whether this test should be skipped during execution.[br]
+## Useful for temporarily disabling tests without removing them.
+var is_skipped := false
+
+
+## Documents why the test is being skipped.[br]
+## [br]
+## Should explain the reason for skipping and ideally include:[br]
+## - Why the test was disabled[br]
+## - Under what conditions it should be re-enabled[br]
+## - Any related issues or tickets
+var skip_reason := "Unknown"
+
+
+## Number of iterations to run when using fuzzers.[br]
+## [br]
+## Fuzzers generate random test data to help find edge cases.[br]
+## Higher values provide better coverage but increase test duration.
+var fuzzer_iterations: int = Fuzzer.ITERATION_DEFAULT_COUNT
+
+
+## Array of fuzzer configurations for test parameters.[br]
+## [br]
+## Each [GdFunctionArgument] defines how random test data[br]
+## should be generated for a particular parameter.
+var fuzzers: Array[GdFunctionArgument] = []
+
+
+# There is a bug in `duplicate` see https://github.com/godotengine/godot/issues/98644
+# we need in addition to overwrite default values with the source values
+@warning_ignore("native_method_override")
+func clone() -> Resource:
+ var copy: TestCaseAttribute = TestCaseAttribute.new()
+ copy.timeout = timeout
+ copy.test_seed = test_seed
+ copy.is_skipped = is_skipped
+ copy.skip_reason = skip_reason
+ copy.fuzzer_iterations = fuzzer_iterations
+ copy.fuzzers = fuzzers.duplicate()
+ return copy
diff --git a/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid
new file mode 100644
index 0000000..653a941
--- /dev/null
+++ b/addons/gdUnit4/src/core/attributes/TestCaseAttribute.gd.uid
@@ -0,0 +1 @@
+uid://cno711hbnapj1
diff --git a/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd
new file mode 100644
index 0000000..f5c5ffb
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd
@@ -0,0 +1,64 @@
+@abstract class_name GdUnitBaseCommand
+extends Node
+
+
+var id: String
+var icon: Texture2D
+var shortcut: Shortcut = null
+var shortcut_type: GdUnitShortcut.ShortCut
+
+
+func _init(p_id: String, p_shortcut: GdUnitShortcut.ShortCut = GdUnitShortcut.ShortCut.NONE) -> void:
+ id = p_id
+ shortcut_type = p_shortcut
+ _set_shortcut()
+
+
+func _shortcut_input(event: InputEvent) -> void:
+ if is_running():
+ return
+
+ if shortcut and shortcut.matches_event(event):
+ execute()
+ get_viewport().set_input_as_handled()
+
+
+func update_shortcut() -> void:
+ _set_shortcut()
+
+
+func _set_shortcut() -> void:
+ if shortcut_type == GdUnitShortcut.ShortCut.NONE:
+ return
+
+ var property_name := GdUnitShortcut.as_property(shortcut_type)
+ var property := GdUnitSettings.get_property(property_name)
+ var keys := GdUnitShortcut.default_keys(shortcut_type)
+ if property != null:
+ keys = property.value()
+ var inputEvent := _create_shortcut_input_even(keys)
+
+ shortcut = Shortcut.new()
+ shortcut.set_events([inputEvent])
+
+
+func _create_shortcut_input_even(key_codes: PackedInt32Array) -> InputEventKey:
+ var inputEvent := InputEventKey.new()
+ inputEvent.pressed = true
+ for key_code in key_codes:
+ match key_code:
+ KEY_ALT:
+ inputEvent.alt_pressed = true
+ KEY_SHIFT:
+ inputEvent.shift_pressed = true
+ KEY_CTRL:
+ inputEvent.ctrl_pressed = true
+ _:
+ inputEvent.keycode = key_code as Key
+ inputEvent.physical_keycode = key_code as Key
+ return inputEvent
+
+
+@abstract func is_running() -> bool
+
+@abstract func execute(...parameters: Array) -> void
diff --git a/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid
new file mode 100644
index 0000000..da0e452
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitBaseCommand.gd.uid
@@ -0,0 +1 @@
+uid://bbetqeco0l3rj
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd
new file mode 100644
index 0000000..6a173c5
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd
@@ -0,0 +1,42 @@
+@abstract class_name GdUnitCommandFileSystem
+extends GdUnitBaseCommand
+
+
+var _test_session_command: GdUnitCommandTestSession
+
+func _init(p_id: String, p_shortcut: GdUnitShortcut.ShortCut, test_session_command: GdUnitCommandTestSession) -> void:
+ super(p_id, p_shortcut)
+ _test_session_command = test_session_command
+
+
+func is_running() -> bool:
+ return _test_session_command.is_running()
+
+
+func execute_tests(paths: PackedStringArray, with_debug: bool) -> void:
+ var suite_scaner := GdUnitTestSuiteScanner.new()
+ var scripts: Array[Script]
+
+ for resource_path in paths:
+ # directories and test-suites are valid to enable the menu
+ if DirAccess.dir_exists_absolute(resource_path):
+ scripts.append_array(suite_scaner.scan_directory(resource_path))
+ continue
+
+ var file_type := resource_path.get_extension()
+ if file_type == "gd" or file_type == "cs":
+ var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(resource_path)
+
+ if GdUnitTestSuiteScanner.is_test_suite(script):
+ scripts.append(script)
+
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new())
+ var tests_to_execute: Array[GdUnitTestCase] = []
+ for script in scripts:
+ GdUnitTestDiscoverer.discover_tests(script, func(test_case: GdUnitTestCase) -> void:
+ tests_to_execute.append(test_case)
+ GdUnitTestDiscoverSink.discover(test_case)
+ )
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0))
+ GdUnitTestDiscoverer.console_log_discover_results(tests_to_execute)
+ _test_session_command.execute(tests_to_execute, with_debug)
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid
new file mode 100644
index 0000000..fd5b2c3
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystem.gd.uid
@@ -0,0 +1 @@
+uid://lo83wc48tvgp
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd
new file mode 100644
index 0000000..c4c553c
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd
@@ -0,0 +1,17 @@
+class_name GdUnitCommandFileSystemDebugTests
+extends GdUnitCommandFileSystem
+
+
+const ID := "Debug FileSystem Tests"
+
+
+func _init(test_session_command: GdUnitCommandTestSession) -> void:
+ super(ID, GdUnitShortcut.ShortCut.RUN_TESTSUITE_DEBUG, test_session_command)
+ icon = GdUnitUiTools.get_icon("PlayStart")
+
+
+func execute(...parameters: Array) -> void:
+ if parameters.is_empty():
+ return
+ var selected_paths: PackedStringArray = parameters[0]
+ execute_tests(selected_paths, true)
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid
new file mode 100644
index 0000000..24e7925
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemDebugTests.gd.uid
@@ -0,0 +1 @@
+uid://beoj16ng5y3di
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd
new file mode 100644
index 0000000..d17413f
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd
@@ -0,0 +1,15 @@
+class_name GdUnitCommandFileSystemRunTests
+extends GdUnitCommandFileSystem
+
+
+const ID := "Run FileSystem Tests"
+
+
+func _init(test_session_command: GdUnitCommandTestSession) -> void:
+ super(ID, GdUnitShortcut.ShortCut.RUN_TESTSUITE, test_session_command)
+ icon = GdUnitUiTools.get_icon("Play")
+
+
+func execute(...parameters: Array) -> void:
+ var selected_paths: PackedStringArray = parameters[0]
+ execute_tests(selected_paths, false)
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid
new file mode 100644
index 0000000..62b55e6
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandFileSystemRunTests.gd.uid
@@ -0,0 +1 @@
+uid://iq85jvyaa48b
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd
new file mode 100644
index 0000000..8e2b2f3
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd
@@ -0,0 +1,126 @@
+class_name GdUnitCommandHandler
+extends Object
+
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+var _commnand_mappings: Dictionary[String, GdUnitBaseCommand]= {}
+var test_session_command := GdUnitCommandTestSession.new()
+
+static func instance() -> GdUnitCommandHandler:
+ return GdUnitSingleton.instance("GdUnitCommandHandler", func() -> GdUnitCommandHandler: return GdUnitCommandHandler.new())
+
+
+@warning_ignore("return_value_discarded")
+func _init() -> void:
+ GdUnitSignals.instance().gdunit_event.connect(_on_event)
+ GdUnitSignals.instance().gdunit_client_disconnected.connect(_on_client_disconnected)
+ GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed)
+
+ _register_command(test_session_command)
+ _register_command(GdUnitCommandStopTestSession.new(test_session_command))
+ _register_command(GdUnitCommandInspectorRunTests.new(test_session_command))
+ _register_command(GdUnitCommandInspectorDebugTests.new(test_session_command))
+ _register_command(GdUnitCommandInspectorRerunTestsUntilFailure.new(test_session_command))
+ _register_command(GdUnitCommandInspectorTreeCollapse.new())
+ _register_command(GdUnitCommandInspectorTreeExpand.new())
+ _register_command(GdUnitCommandScriptEditorRunTests.new(test_session_command))
+ _register_command(GdUnitCommandScriptEditorDebugTests.new(test_session_command))
+ _register_command(GdUnitCommandScriptEditorCreateTest.new())
+ _register_command(GdUnitCommandFileSystemRunTests.new(test_session_command))
+ _register_command(GdUnitCommandFileSystemDebugTests.new(test_session_command))
+ _register_command(GdUnitCommandRunTestsOverall.new(test_session_command))
+
+ # schedule discover tests if enabled and running inside the editor
+ if Engine.is_editor_hint() and GdUnitSettings.is_test_discover_enabled():
+ var timer :SceneTreeTimer = (Engine.get_main_loop() as SceneTree).create_timer(5)
+ @warning_ignore("return_value_discarded")
+ timer.timeout.connect(cmd_discover_tests)
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE:
+ for command: GdUnitBaseCommand in _commnand_mappings.values():
+ if Engine.is_editor_hint():
+ EditorInterface.get_command_palette().remove_command("GdUnit4/"+command.id)
+ command.free()
+ _commnand_mappings.clear()
+
+
+func _do_process() -> void:
+ # Do stop test execution when the user has stoped the main scene manually
+ if test_session_command._is_debug and test_session_command.is_running() and not EditorInterface.is_playing_scene():
+ if GdUnitSettings.is_verbose_assert_warnings():
+ print_debug("Test Runner scene was stopped manually, force stopping the current test run!")
+ command_execute(GdUnitCommandStopTestSession.ID)
+
+
+func command_icon(command_id: String) -> Texture2D:
+ if not _commnand_mappings.has(command_id):
+ push_error("GdUnitCommandHandler:command_icon(): No command id '%s' is registered." % command_id)
+ print_stack()
+ return
+ return _commnand_mappings[command_id].icon
+
+
+func command_shortcut(command_id: String) -> Shortcut:
+ if not _commnand_mappings.has(command_id):
+ push_error("GdUnitCommandHandler:command_shortcut(): No command id '%s' is registered." % command_id)
+ print_stack()
+ return
+ return _commnand_mappings[command_id].shortcut
+
+
+func command_execute(...parameters: Array) -> void:
+ if parameters.is_empty():
+ push_error("Invalid arguments used on CommandHandler:execute()! Expecting [<command_id, args...>]")
+ print_stack()
+ return
+
+ var command_id: String = parameters.pop_front()
+ if not _commnand_mappings.has(command_id):
+ push_error("GdUnitCommandHandler:command_execute(): No command id '%s' is registered." % command_id)
+ print_stack()
+ return
+ await _commnand_mappings[command_id].callv("execute", parameters)
+
+
+func _register_command(command: GdUnitBaseCommand) -> void:
+ # first verify the command is not already registerd
+ if _commnand_mappings.has(command.id):
+ push_error("GdUnitCommandHandler:_register_command(): Command with id '%s' is already registerd!" % command.id)
+ return
+
+ _commnand_mappings[command.id] = command
+ if Engine.is_editor_hint():
+ EditorInterface.get_base_control().add_child(command)
+ EditorInterface.get_command_palette().add_command(command.id, "GdUnit4/"+command.id, command.execute, command.shortcut.get_as_text() if command.shortcut else "None")
+
+
+func cmd_discover_tests() -> void:
+ await GdUnitTestDiscoverer.run()
+
+
+################################################################################
+# signals handles
+################################################################################
+func _on_event(event: GdUnitEvent) -> void:
+ if event.type() == GdUnitEvent.SESSION_CLOSE:
+ command_execute(GdUnitCommandStopTestSession.ID)
+
+
+func _on_settings_changed(property: GdUnitProperty) -> void:
+ for command: GdUnitBaseCommand in _commnand_mappings.values():
+ command.update_shortcut()
+
+ if property.name() == GdUnitSettings.TEST_DISCOVER_ENABLED:
+ var timer :SceneTreeTimer = (Engine.get_main_loop() as SceneTree).create_timer(3)
+ @warning_ignore("return_value_discarded")
+ timer.timeout.connect(cmd_discover_tests)
+
+
+################################################################################
+# Network stuff
+################################################################################
+func _on_client_disconnected(_client_id: int) -> void:
+ command_execute(GdUnitCommandStopTestSession.ID)
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid
new file mode 100644
index 0000000..1a34d39
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandHandler.gd.uid
@@ -0,0 +1 @@
+uid://m5es1m3vrm50
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd
new file mode 100644
index 0000000..cb4a634
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd
@@ -0,0 +1,27 @@
+class_name GdUnitCommandInspectorDebugTests
+extends GdUnitBaseCommand
+
+const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd")
+const ID := "Debug Inspector Tests"
+
+
+var _test_session_command: GdUnitCommandTestSession
+
+
+func _init(test_session_command: GdUnitCommandTestSession) -> void:
+ super(ID, GdUnitShortcut.ShortCut.RERUN_TESTS_DEBUG)
+ icon = GdUnitUiTools.get_icon("PlayStart")
+ _test_session_command = test_session_command
+
+
+func is_running() -> bool:
+ return _test_session_command.is_running()
+
+
+func execute(..._parameters: Array) -> void:
+ var base_control := EditorInterface.get_base_control()
+ var inspector: InspectorTreeMainPanel = base_control.get_meta("GdUnit4Inspector")
+ var selected_item := inspector._tree.get_selected()
+ var tests_to_execute := inspector.collect_test_cases(selected_item)
+
+ _test_session_command.execute(tests_to_execute, true)
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid
new file mode 100644
index 0000000..fd8150f
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorDebugTests.gd.uid
@@ -0,0 +1 @@
+uid://c7qpkaykv4f4a
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd
new file mode 100644
index 0000000..e2ec5e4
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd
@@ -0,0 +1,57 @@
+class_name GdUnitCommandInspectorRerunTestsUntilFailure
+extends GdUnitBaseCommand
+
+
+signal session_closed()
+
+
+const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd")
+const ID := "Rerun Inspector Tests Until Failure"
+
+
+var _test_session_command: GdUnitCommandTestSession
+var _current_execution_count := 0
+
+
+func _init(test_session_command: GdUnitCommandTestSession) -> void:
+ super(ID, GdUnitShortcut.ShortCut.RERUN_TESTS_UNTIL_FAILURE)
+ icon = GdUnitUiTools.get_icon("Play")
+ _test_session_command = test_session_command
+
+
+func is_running() -> bool:
+ return _test_session_command.is_running()
+
+
+func execute(..._parameters: Array) -> void:
+ var base_control := EditorInterface.get_base_control()
+ var inspector: InspectorTreeMainPanel = base_control.get_meta("GdUnit4Inspector")
+ var selected_item := inspector._tree.get_selected()
+ var tests_to_execute := inspector.collect_test_cases(selected_item)
+ var rerun_until_failure_count := GdUnitSettings.get_rerun_max_retries()
+ var saved_settings: bool = ProjectSettings.get_setting(GdUnitSettings.TEST_FLAKY_CHECK)
+ ProjectSettings.set_setting(GdUnitSettings.TEST_FLAKY_CHECK, false)
+
+ GdUnitSignals.instance().gdunit_event.connect(_on_test_event)
+ _current_execution_count = 0
+
+ _test_session_command._is_fail_fast = true
+ while _current_execution_count < rerun_until_failure_count:
+ _test_session_command.execute(tests_to_execute, true)
+ await session_closed
+ _test_session_command._is_fail_fast = false
+
+ ProjectSettings.set_setting(GdUnitSettings.TEST_FLAKY_CHECK, saved_settings)
+ GdUnitSignals.instance().gdunit_event.disconnect(_on_test_event)
+
+
+func _on_test_event(event: GdUnitEvent) -> void:
+ if event.type() == GdUnitEvent.SESSION_START:
+ _current_execution_count += 1
+ GdUnitSignals.instance().gdunit_message.emit("[color=RED]Execution Mode: ReRun until failure! (iteration %d)[/color]" % _current_execution_count)
+ if event.type() == GdUnitEvent.SESSION_CLOSE:
+ session_closed.emit()
+ if event.type() == GdUnitEvent.TESTCASE_AFTER:
+ if not event.is_success():
+ GdUnitSignals.instance().gdunit_message.emit(" [color=RED](iteration: %d)[/color]" % _current_execution_count)
+ _current_execution_count = 9999
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid
new file mode 100644
index 0000000..d589f76
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRerunTestsUntilFailure.gd.uid
@@ -0,0 +1 @@
+uid://d0pvhydsl86mf
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd
new file mode 100644
index 0000000..c0cb733
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd
@@ -0,0 +1,26 @@
+class_name GdUnitCommandInspectorRunTests
+extends GdUnitBaseCommand
+
+const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd")
+const ID := "Run Inspector Tests"
+
+
+var _test_session_command: GdUnitCommandTestSession
+
+
+func _init(test_session_command: GdUnitCommandTestSession) -> void:
+ super(ID, GdUnitShortcut.ShortCut.RERUN_TESTS)
+ icon = GdUnitUiTools.get_icon("Play")
+ _test_session_command = test_session_command
+
+
+func is_running() -> bool:
+ return _test_session_command.is_running()
+
+
+func execute(..._parameters: Array) -> void:
+ var base_control := EditorInterface.get_base_control()
+ var inspector: InspectorTreeMainPanel = base_control.get_meta("GdUnit4Inspector")
+ var selected_item := inspector._tree.get_selected()
+ var tests_to_execute := inspector.collect_test_cases(selected_item)
+ _test_session_command.execute(tests_to_execute, false)
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid
new file mode 100644
index 0000000..2d3aee1
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorRunTests.gd.uid
@@ -0,0 +1 @@
+uid://dcrjckjo7ur1g
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd
new file mode 100644
index 0000000..fa784f8
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd
@@ -0,0 +1,26 @@
+class_name GdUnitCommandInspectorTreeCollapse
+extends GdUnitBaseCommand
+
+const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd")
+const ID := "Inspector Tree Collapse"
+
+
+func _init() -> void:
+ super(ID, GdUnitShortcut.ShortCut.NONE)
+ icon = GdUnitUiTools.get_icon("CollapseTree")
+
+
+func is_running() -> bool:
+ return false
+
+
+func execute(..._parameters: Array) -> void:
+ var inspector: InspectorTreeMainPanel = EditorInterface.get_base_control().get_meta("GdUnit4Inspector")
+ var selected_item := inspector._tree.get_selected()
+ if selected_item == null:
+ selected_item = inspector._tree.get_root()
+ else:
+ selected_item = selected_item.get_parent()
+
+ inspector.do_collapse_all(false, selected_item)
+ inspector.do_collapse_all(true, selected_item)
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid
new file mode 100644
index 0000000..810921d
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeCollapse.gd.uid
@@ -0,0 +1 @@
+uid://c1loc8h46b4m0
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd
new file mode 100644
index 0000000..76f8d0b
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd
@@ -0,0 +1,25 @@
+class_name GdUnitCommandInspectorTreeExpand
+extends GdUnitBaseCommand
+
+const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd")
+const ID := "Inspector Tree Expand"
+
+
+func _init() -> void:
+ super(ID, GdUnitShortcut.ShortCut.NONE)
+ icon = GdUnitUiTools.get_icon("ExpandTree")
+
+
+func is_running() -> bool:
+ return false
+
+
+func execute(..._parameters: Array) -> void:
+ var inspector: InspectorTreeMainPanel = EditorInterface.get_base_control().get_meta("GdUnit4Inspector")
+ var selected_item := inspector._tree.get_selected()
+ if selected_item == null:
+ selected_item = inspector._tree.get_root()
+ else:
+ selected_item = selected_item.get_parent()
+
+ inspector.do_collapse_all(false, selected_item)
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid
new file mode 100644
index 0000000..4f3fad5
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandInspectorTreeExpand.gd.uid
@@ -0,0 +1 @@
+uid://dc1uaxrnrvesy
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd
new file mode 100644
index 0000000..daf534b
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd
@@ -0,0 +1,22 @@
+class_name GdUnitCommandRunTestsOverall
+extends GdUnitBaseCommand
+
+const ID := "Run Tests Overall"
+
+
+var _test_session_command: GdUnitCommandTestSession
+
+
+func _init(test_session_command: GdUnitCommandTestSession) -> void:
+ super(ID, GdUnitShortcut.ShortCut.RUN_TESTS_OVERALL)
+ icon = GdUnitUiTools.get_run_overall_icon()
+ _test_session_command = test_session_command
+
+
+func is_running() -> bool:
+ return _test_session_command.is_running()
+
+
+func execute(..._parameters: Array) -> void:
+ var tests_to_execute := await GdUnitTestDiscoverer.run()
+ _test_session_command.execute(tests_to_execute, true)
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid
new file mode 100644
index 0000000..4e38afe
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandRunTestsOverall.gd.uid
@@ -0,0 +1 @@
+uid://b2oyb1jkqagjc
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd
new file mode 100644
index 0000000..5504fa7
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd
@@ -0,0 +1,56 @@
+@abstract class_name GdUnitCommandScriptEditor
+extends GdUnitBaseCommand
+
+var _test_session_command: GdUnitCommandTestSession
+
+func _init(p_id: String, p_shortcut: GdUnitShortcut.ShortCut, test_session_command: GdUnitCommandTestSession) -> void:
+ super(p_id, p_shortcut)
+ _test_session_command = test_session_command
+
+
+func is_running() -> bool:
+ return _test_session_command.is_running()
+
+
+func execute_tests(with_debug: bool) -> void:
+ var selected_tests := PackedStringArray()
+ if _is_active_script_editor():
+ var cursor_line := _active_base_editor().get_caret_line()
+ #run test case?
+ var regex := RegEx.new()
+ @warning_ignore("return_value_discarded")
+ regex.compile("(^func[ ,\t])(test_[a-zA-Z0-9_]*)")
+ var result := regex.search(_active_base_editor().get_line(cursor_line))
+ if result:
+ var func_name := result.get_string(2).strip_edges()
+ if func_name.begins_with("test_"):
+ selected_tests.append(func_name)
+
+ var tests_to_execute := _collect_tests(_active_script(), selected_tests)
+ _test_session_command.execute(tests_to_execute, with_debug)
+
+
+func _collect_tests(script: Script, tests: PackedStringArray) -> Array[GdUnitTestCase]:
+ # Update test discovery
+ var tests_to_execute: Array[GdUnitTestCase] = []
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new())
+ GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void:
+ if tests.is_empty() or tests.has(test.test_name):
+ tests_to_execute.append(test)
+ GdUnitTestDiscoverSink.discover(test)
+ )
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0))
+ GdUnitTestDiscoverer.console_log_discover_results(tests_to_execute)
+ return tests_to_execute
+
+
+func _is_active_script_editor() -> bool:
+ return EditorInterface.get_script_editor().get_current_editor() != null
+
+
+func _active_base_editor() -> TextEdit:
+ return EditorInterface.get_script_editor().get_current_editor().get_base_editor()
+
+
+func _active_script() -> Script:
+ return EditorInterface.get_script_editor().get_current_script()
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid
new file mode 100644
index 0000000..619f309
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditor.gd.uid
@@ -0,0 +1 @@
+uid://b1ftsa2a1ba1i
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd
new file mode 100644
index 0000000..17876ca
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd
@@ -0,0 +1,41 @@
+class_name GdUnitCommandScriptEditorCreateTest
+extends GdUnitBaseCommand
+
+
+const ID := "Create Test"
+
+
+func _init() -> void:
+ super(ID, GdUnitShortcut.ShortCut.CREATE_TEST)
+ icon = GdUnitUiTools.get_icon("New")
+
+
+func is_running() -> bool:
+ return false
+
+
+func execute(..._parameters: Array) -> void:
+ if not _is_active_script_editor():
+ return
+ var cursor_line := _active_base_editor().get_caret_line()
+ var result := GdUnitTestSuiteBuilder.create(_active_script(), cursor_line)
+ if result.is_error():
+ # show error dialog
+ push_error("Failed to create test case: %s" % result.error_message())
+ return
+ var info: Dictionary = result.value()
+ var script_path: String = info.get("path")
+ var script_line: int = info.get("line")
+ ScriptEditorControls.edit_script(script_path, script_line)
+
+
+func _is_active_script_editor() -> bool:
+ return EditorInterface.get_script_editor().get_current_editor() != null
+
+
+func _active_base_editor() -> TextEdit:
+ return EditorInterface.get_script_editor().get_current_editor().get_base_editor()
+
+
+func _active_script() -> Script:
+ return EditorInterface.get_script_editor().get_current_script()
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid
new file mode 100644
index 0000000..fb6d1f9
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorCreateTest.gd.uid
@@ -0,0 +1 @@
+uid://dikmhnnpgxyhs
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd
new file mode 100644
index 0000000..8ab9f24
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd
@@ -0,0 +1,14 @@
+class_name GdUnitCommandScriptEditorDebugTests
+extends GdUnitCommandScriptEditor
+
+
+const ID := "Debug ScriptEditor Tests"
+
+
+func _init(test_session_command: GdUnitCommandTestSession) -> void:
+ super(ID, GdUnitShortcut.ShortCut.RUN_TESTCASE_DEBUG, test_session_command)
+ icon = GdUnitUiTools.get_icon("PlayStart")
+
+
+func execute(..._parameters: Array) -> void:
+ execute_tests(true)
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid
new file mode 100644
index 0000000..e575fb8
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorDebugTests.gd.uid
@@ -0,0 +1 @@
+uid://bthuj4vyx2erm
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd
new file mode 100644
index 0000000..a18851a
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd
@@ -0,0 +1,14 @@
+class_name GdUnitCommandScriptEditorRunTests
+extends GdUnitCommandScriptEditor
+
+
+const ID := "Run ScriptEditor Tests"
+
+
+func _init(test_session_command: GdUnitCommandTestSession) -> void:
+ super(ID, GdUnitShortcut.ShortCut.RUN_TESTCASE, test_session_command)
+ icon = GdUnitUiTools.get_icon("Play")
+
+
+func execute(..._parameters: Array) -> void:
+ execute_tests(false)
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid
new file mode 100644
index 0000000..4889e26
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandScriptEditorRunTests.gd.uid
@@ -0,0 +1 @@
+uid://c5w4h304l54c6
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd
new file mode 100644
index 0000000..50a6df7
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd
@@ -0,0 +1,21 @@
+class_name GdUnitCommandStopTestSession
+extends GdUnitBaseCommand
+
+const ID := "Stop Test Session"
+
+
+var _test_session_command: GdUnitCommandTestSession
+
+
+func _init(test_session_command: GdUnitCommandTestSession) -> void:
+ super(ID, GdUnitShortcut.ShortCut.STOP_TEST_RUN)
+ icon = GdUnitUiTools.get_icon("Stop")
+ _test_session_command = test_session_command
+
+
+func is_running() -> bool:
+ return _test_session_command.is_running()
+
+
+func execute(..._parameters: Array) -> void:
+ await _test_session_command.stop()
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid
new file mode 100644
index 0000000..a799bf6
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandStopTestSession.gd.uid
@@ -0,0 +1 @@
+uid://b33mmwenpaj4m
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd
new file mode 100644
index 0000000..53d74ed
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd
@@ -0,0 +1,124 @@
+class_name GdUnitCommandTestSession
+extends GdUnitBaseCommand
+
+
+const ID := "Start Test Session"
+
+
+var _current_runner_process_id: int
+var _is_running: bool
+var _is_debug: bool
+var _is_fail_fast: bool
+
+
+func _init() -> void:
+ super(ID, GdUnitShortcut.ShortCut.NONE)
+ _is_running = false
+ _is_fail_fast = false
+
+
+func is_running() -> bool:
+ return _is_running
+
+
+func stop() -> void:
+ if not is_running():
+ return
+ _is_running = false
+
+ if _is_debug:
+ force_pause_scene()
+
+ GdUnitSignals.instance().gdunit_test_session_terminate.emit()
+ # Give the API time to commit terminate to the client
+ await get_tree().create_timer(.5).timeout
+
+ if _is_debug and EditorInterface.is_playing_scene():
+ EditorInterface.stop_playing_scene()
+ # We need finaly to send the test session close event because the current run is hard aborted.
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionClose.new())
+ elif OS.is_process_running(_current_runner_process_id):
+ var result := OS.kill(_current_runner_process_id)
+ if result != OK:
+ push_error("ERROR checked stopping GdUnit Test Runner. error code: %s" % result)
+ _current_runner_process_id = -1
+ # We need finaly to send the test session close event because the current run is hard aborted.
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionClose.new())
+
+
+## Forces the running scene to unpause when the debugger hits a breakpoint.[br]
+## [br]
+## When the Godot debugger stops at a breakpoint during test execution, it blocks[br]
+## the main thread. This prevents signals and TCP communications from being processed,[br]
+## which can cause GdUnit4 tests to hang or fail to communicate properly with the[br]
+## test runner. This function programmatically unpauses the scene to restore[br]
+## main thread execution while maintaining debugger functionality. [br]
+## [br]
+## [b]Technical Background:[/b][br]
+## - Debugger breakpoints freeze the main thread to allow inspection[br]
+## - Frozen main thread blocks signal processing and network communications[br]
+## - GdUnit4 requires active signal/TCP processing for test coordination[br]
+## - This function finds and triggers the editor's pause button to resume execution[br]
+## [br]
+## [b]How It Works:[/b][br]
+## 1. Locates the EditorRunBar in the Godot editor UI hierarchy[br]
+## 2. Searches for the pause button by matching its icon[br]
+## 3. Unpresses the button if it's currently pressed (paused state)[br]
+## 4. Manually triggers the button's connected callbacks to resume execution[br]
+func force_pause_scene() -> bool:
+ var nodes := EditorInterface.get_base_control().find_children("*", "EditorRunBar", true, false)
+ if nodes.size() != 1:
+ push_error("GdUnitCommandTestSession:force_pause_scene() Can't find Editor component 'EditorRunBar'")
+ return false
+ var editor_run_bar := nodes[0]
+ var containers := editor_run_bar.find_children("*", "HBoxContainer", true, false)
+ var pause_icon := GdUnitUiTools.get_icon("Pause")
+
+ for container in containers:
+ for child in container.get_children():
+ if child is Button:
+ var button: Button = child
+ if pause_icon == button.icon:
+ button.set_pressed(false)
+
+ var connected_signals := button.get_signal_connection_list("pressed")
+ if not connected_signals.is_empty():
+ for signal_ in connected_signals:
+ var cb: Callable = signal_["callable"]
+ cb.call()
+ return true
+ push_error("GdUnitCommandTestSession:force_pause_scene() Can't find Editor component 'EditorRunBar'")
+ return false
+
+
+func execute(...parameters: Array) -> void:
+ var tests_to_execute: Array[GdUnitTestCase] = parameters[0]
+ _is_debug = parameters[1]
+
+ _prepare_test_session(tests_to_execute)
+ if _is_debug:
+ EditorInterface.play_custom_scene("res://addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn")
+ else:
+ var arguments := Array()
+ if OS.is_stdout_verbose():
+ arguments.append("--verbose")
+ arguments.append("--no-window")
+ arguments.append("--path")
+ arguments.append(ProjectSettings.globalize_path("res://"))
+ arguments.append("res://addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn")
+ _current_runner_process_id = OS.create_process(OS.get_executable_path(), arguments, false);
+ _is_running = true
+
+
+func _prepare_test_session(tests_to_execute: Array[GdUnitTestCase]) -> void:
+ var server_port: int = Engine.get_meta("gdunit_server_port")
+ var result := GdUnitRunnerConfig.new() \
+ .set_server_port(server_port) \
+ .do_fail_fast(_is_fail_fast) \
+ .add_test_cases(tests_to_execute) \
+ .save_config()
+ if result.is_error():
+ push_error(result.error_message())
+ return
+ # before start we have to save all scrpt changes
+ ScriptEditorControls.save_all_open_script()
diff --git a/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid
new file mode 100644
index 0000000..8e395e2
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitCommandTestSession.gd.uid
@@ -0,0 +1 @@
+uid://camw8m61ljvpl
diff --git a/addons/gdUnit4/src/core/command/GdUnitShortcut.gd b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd
new file mode 100644
index 0000000..33af082
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd
@@ -0,0 +1,73 @@
+class_name GdUnitShortcut
+extends RefCounted
+
+
+enum ShortCut {
+ NONE,
+ RUN_TESTS_OVERALL,
+ RUN_TESTCASE,
+ RUN_TESTCASE_DEBUG,
+ RUN_TESTSUITE,
+ RUN_TESTSUITE_DEBUG,
+ RERUN_TESTS,
+ RERUN_TESTS_DEBUG,
+ RERUN_TESTS_UNTIL_FAILURE,
+ STOP_TEST_RUN,
+ CREATE_TEST,
+}
+
+const DEFAULTS_MACOS := {
+ ShortCut.NONE : [],
+ ShortCut.RUN_TESTCASE : [Key.KEY_META, Key.KEY_ALT, Key.KEY_F5],
+ ShortCut.RUN_TESTCASE_DEBUG : [Key.KEY_META, Key.KEY_ALT, Key.KEY_F6],
+ ShortCut.RUN_TESTSUITE : [Key.KEY_ALT, Key.KEY_META, Key.KEY_F5],
+ ShortCut.RUN_TESTSUITE_DEBUG : [Key.KEY_ALT, Key.KEY_META, Key.KEY_F6],
+ ShortCut.RUN_TESTS_OVERALL : [Key.KEY_ALT, Key.KEY_F7],
+ ShortCut.STOP_TEST_RUN : [Key.KEY_ALT, Key.KEY_F8],
+ ShortCut.RERUN_TESTS : [Key.KEY_ALT, Key.KEY_F5],
+ ShortCut.RERUN_TESTS_DEBUG : [Key.KEY_ALT, Key.KEY_F6],
+ ShortCut.RERUN_TESTS_UNTIL_FAILURE : [Key.KEY_ALT, Key.KEY_META, Key.KEY_F5],
+ ShortCut.CREATE_TEST : [Key.KEY_META, Key.KEY_ALT, Key.KEY_F10],
+}
+
+const DEFAULTS_WINDOWS := {
+ ShortCut.NONE : [],
+ ShortCut.RUN_TESTCASE : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F5],
+ ShortCut.RUN_TESTCASE_DEBUG : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F6],
+ ShortCut.RUN_TESTSUITE : [Key.KEY_ALT, Key.KEY_SHIFT, Key.KEY_F5],
+ ShortCut.RUN_TESTSUITE_DEBUG : [Key.KEY_ALT, Key.KEY_SHIFT, Key.KEY_F6],
+ ShortCut.RUN_TESTS_OVERALL : [Key.KEY_ALT, Key.KEY_F7],
+ ShortCut.STOP_TEST_RUN : [Key.KEY_ALT, Key.KEY_F8],
+ ShortCut.RERUN_TESTS : [Key.KEY_ALT, Key.KEY_F5],
+ ShortCut.RERUN_TESTS_DEBUG : [Key.KEY_ALT, Key.KEY_F6],
+ ShortCut.RERUN_TESTS_UNTIL_FAILURE : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F5],
+ ShortCut.CREATE_TEST : [Key.KEY_CTRL, Key.KEY_ALT, Key.KEY_F10],
+}
+
+
+const SETTINGS_MAPPING: Dictionary[ShortCut, String] = {
+ ShortCut.RUN_TESTCASE : GdUnitSettings.SHORTCUT_EDITOR_RUN_TEST,
+ ShortCut.RUN_TESTCASE_DEBUG : GdUnitSettings.SHORTCUT_EDITOR_RUN_TEST_DEBUG,
+ ShortCut.CREATE_TEST : GdUnitSettings.SHORTCUT_EDITOR_CREATE_TEST,
+ ShortCut.RERUN_TESTS : GdUnitSettings.SHORTCUT_INSPECTOR_RERUN_TEST,
+ ShortCut.RERUN_TESTS_DEBUG : GdUnitSettings.SHORTCUT_INSPECTOR_RERUN_TEST_DEBUG,
+ ShortCut.RERUN_TESTS_UNTIL_FAILURE : GdUnitSettings.SHORTCUT_INSPECTOR_RERUN_TEST_UNTIL_FAILURE,
+ ShortCut.STOP_TEST_RUN : GdUnitSettings.SHORTCUT_INSPECTOR_RUN_TEST_STOP,
+ ShortCut.RUN_TESTS_OVERALL : GdUnitSettings.SHORTCUT_INSPECTOR_RUN_TEST_OVERALL,
+ ShortCut.RUN_TESTSUITE : GdUnitSettings.SHORTCUT_FILESYSTEM_RUN_TEST,
+ ShortCut.RUN_TESTSUITE_DEBUG : GdUnitSettings.SHORTCUT_FILESYSTEM_RUN_TEST_DEBUG
+}
+
+
+static func as_property(sortcut: ShortCut) -> String:
+ return SETTINGS_MAPPING[sortcut]
+
+
+static func default_keys(shortcut: ShortCut) -> PackedInt32Array:
+ match OS.get_name().to_lower():
+ 'windows':
+ return DEFAULTS_WINDOWS[shortcut]
+ 'macos':
+ return DEFAULTS_MACOS[shortcut]
+ _:
+ return DEFAULTS_WINDOWS[shortcut]
diff --git a/addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid
new file mode 100644
index 0000000..841bcaf
--- /dev/null
+++ b/addons/gdUnit4/src/core/command/GdUnitShortcut.gd.uid
@@ -0,0 +1 @@
+uid://tyumhj8vfdaf
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd
new file mode 100644
index 0000000..00332f9
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd
@@ -0,0 +1,46 @@
+## A class representing a globally unique identifier for GdUnit test elements.
+## Uses random values to generate unique identifiers that can be used
+## to track and reference test cases and suites across the test framework.
+class_name GdUnitGUID
+extends RefCounted
+
+
+## The internal string representation of the GUID.
+## Generated using Godot's ResourceUID system when no existing GUID is provided.
+var _guid: String
+
+
+## Creates a new GUID instance.
+## If no GUID is provided, generates a new one using Godot's ResourceUID system.
+func _init(from_guid: String = "") -> void:
+ if from_guid.is_empty():
+ _guid = _generate_guid()
+ else:
+ _guid = from_guid
+
+
+## Compares this GUID with another for equality.
+## Returns true if both GUIDs represent the same unique identifier.
+func equals(other: GdUnitGUID) -> bool:
+ return other._guid == _guid
+
+
+## Generates a custom GUID using random bytes.[br]
+## The format uses 16 random bytes encoded to hex and formatted with hyphens.
+static func _generate_guid() -> String:
+ # Pre-allocate array with exact size needed
+ var bytes := PackedByteArray()
+ bytes.resize(16)
+
+ # Fill with random bytes
+ for i in range(16):
+ bytes[i] = randi() % 256
+
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
+
+ return bytes.hex_encode().insert(8, "-").insert(16, "-").insert(24, "-")
+
+
+func _to_string() -> String:
+ return _guid
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid
new file mode 100644
index 0000000..78730f9
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitGUID.gd.uid
@@ -0,0 +1 @@
+uid://caisrhebqar04
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd
new file mode 100644
index 0000000..766f9ea
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd
@@ -0,0 +1,125 @@
+## GdUnitTestCase
+## A class representing a single test case in GdUnit4.
+## This class is used as a data container to hold all relevant information about a test case,
+## including its location, dependencies, and metadata for test discovery and execution.
+
+class_name GdUnitTestCase
+extends RefCounted
+
+## A unique identifier for the test case. Used to track and reference specific test instances.
+var guid := GdUnitGUID.new()
+
+## The resource path to the test suite
+var suite_resource_path: String
+
+## The name of the test method/function. Should start with "test_" prefix.
+var test_name: String
+
+## The class name of the test suite containing this test case.
+var suite_name: String
+
+## The fully qualified name of the test case following C# namespace pattern:
+## Constructed from the folder path (where folders are dot-separated), the test suite name, and the test case name.
+## All parts are joined by dots: {folder1.folder2.folder3}.{suite_name}.{test_name}
+var fully_qualified_name: String
+
+var display_name: String
+
+## Index tracking test attributes for ordered execution. Default is 0.
+## Higher values indicate later execution in the test sequence.
+var attribute_index: int
+
+## Flag indicating if this test requires the Godot runtime environment.
+## Tests requiring runtime cannot be executed in isolation.
+var require_godot_runtime: bool = true
+
+## The path to the source file containing this test case.
+## Used for test discovery and execution.
+var source_file: String
+
+## Optional holds the assembly location for C# tests
+var assembly_location: String = ""
+
+## The line number where the test case is defined in the source file.
+## Used for navigation and error reporting.
+var line_number: int = -1
+
+## Additional metadata about the test case, such as:
+## - tags: Array[String] - Test categories/tags for filtering
+## - timeout: int - Maximum execution time in milliseconds
+## - skip: bool - Whether the test should be skipped
+## - dependencies: Array[String] - Required test dependencies
+var metadata: Dictionary = {}
+
+
+static func from_dict(dict: Dictionary) -> GdUnitTestCase:
+ var test := GdUnitTestCase.new()
+ test.guid = GdUnitGUID.new(str(dict["guid"]))
+ test.suite_resource_path = dict["suite_resource_path"] if dict.has("suite_resource_path") else dict["source_file"]
+ test.suite_name = dict["managed_type"]
+ test.test_name = dict["test_name"]
+ test.display_name = dict["simple_name"]
+ test.fully_qualified_name = dict["fully_qualified_name"]
+ test.attribute_index = dict["attribute_index"]
+ test.source_file = dict["source_file"]
+ test.line_number = dict["line_number"]
+ test.require_godot_runtime = dict["require_godot_runtime"]
+ test.assembly_location = dict["assembly_location"]
+ return test
+
+
+static func to_dict(test: GdUnitTestCase) -> Dictionary:
+ return {
+ "guid": test.guid._guid,
+ "suite_resource_path": test.suite_resource_path,
+ "managed_type": test.suite_name,
+ "test_name" : test.test_name,
+ "simple_name" : test.display_name,
+ "fully_qualified_name" : test.fully_qualified_name,
+ "attribute_index" : test.attribute_index,
+ "source_file" : test.source_file,
+ "line_number" : test.line_number,
+ "require_godot_runtime" : test.require_godot_runtime,
+ "assembly_location" : test.assembly_location
+ }
+
+
+static func from(_suite_resource_path: String, _source_file: String, _line_number: int, _test_name: String, _attribute_index := -1, _test_parameters := "") -> GdUnitTestCase:
+ if(_source_file == null or _source_file.is_empty()):
+ prints(_test_name)
+
+ assert(_test_name != null and not _test_name.is_empty(), "Precondition: The parameter 'test_name' is not set")
+ assert(_source_file != null and not _source_file.is_empty(), "Precondition: The parameter 'source_file' is not set")
+
+ var test := GdUnitTestCase.new()
+ test.suite_resource_path = _suite_resource_path
+ test.test_name = _test_name
+ test.source_file = _source_file
+ test.line_number = _line_number
+ test.attribute_index = _attribute_index
+ test._build_suite_name()
+ test._build_display_name(_test_parameters)
+ test._build_fully_qualified_name(_suite_resource_path)
+ return test
+
+
+func _build_suite_name() -> void:
+ suite_name = source_file.get_file().get_basename()
+ assert(suite_name != null and not suite_name.is_empty(), "Precondition: The parameter 'suite_name' can't be resolved")
+
+
+func _build_display_name(_test_parameters: String) -> void:
+ if attribute_index == -1:
+ display_name = test_name
+ else:
+ display_name = "%s:%d (%s)" % [test_name, attribute_index, _test_parameters.trim_prefix("[").trim_suffix("]").replace('"', "'")]
+
+
+func _build_fully_qualified_name(_resource_path: String) -> void:
+ var name_space := _resource_path.trim_prefix("res://").trim_suffix(".gd").trim_suffix(".cs").replace("/", ".")
+
+ if attribute_index == -1:
+ fully_qualified_name = "%s.%s" % [name_space, test_name]
+ else:
+ fully_qualified_name = "%s.%s.%s" % [name_space, test_name, display_name]
+ assert(fully_qualified_name != null and not fully_qualified_name.is_empty(), "Precondition: The parameter 'fully_qualified_name' can't be resolved")
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid
new file mode 100644
index 0000000..0edbaff
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestCase.gd.uid
@@ -0,0 +1 @@
+uid://ct8h4c11708ha
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd
new file mode 100644
index 0000000..6af8255
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd
@@ -0,0 +1,323 @@
+## Guards and tracks test case changes during test discovery and file modifications.[br]
+## [br]
+## This guard maintains a cache of discovered tests to track changes between test runs and during[br]
+## file modifications. It is optimized for performance using simple but effective test identity checks.[br]
+## [br]
+## Test Change Detection:[br]
+## - Moved tests: The test implementation remains at a different line number[br]
+## - Renamed tests: The test line position remains but the test name changed[br]
+## - Deleted tests: A previously discovered test was removed[br]
+## - Added tests: A new test was discovered[br]
+## [br]
+## Cache Management:[br]
+## - Maintains test identity through unique GdUnitTestCase GUIDs[br]
+## - Maps source files to their discovered test cases[br]
+## - Tracks only essential metadata (line numbers, names) to minimize memory use[br]
+## [br]
+## Change Detection Strategy:[br]
+## The guard uses a lightweight approach by comparing only line numbers and test names.[br]
+## This avoids expensive operations like test content parsing or similarity checks.[br]
+## [br]
+## Event Handling:[br]
+## - Emits events on test changes through GdUnitSignals[br]
+## - Synchronizes cache with test discovery events[br]
+## - Notifies UI about test changes[br]
+## [br]
+## Example usage:[br]
+## [codeblock]
+## # Create guard for tracking test changes
+## var guard := GdUnitTestDiscoverGuard.new()
+##
+## # Connect to test discovery events
+## GdUnitSignals.instance().gdunit_test_discovered.connect(guard.sync_test_added)
+##
+## # Discover tests and track changes
+## await guard.discover(test_script)
+## [/codeblock]
+class_name GdUnitTestDiscoverGuard
+extends Object
+
+
+
+static func instance() -> GdUnitTestDiscoverGuard:
+ return GdUnitSingleton.instance("GdUnitTestDiscoverGuard", func() -> GdUnitTestDiscoverGuard:
+ return GdUnitTestDiscoverGuard.new()
+ )
+
+
+## Maps source files to their discovered test cases.[br]
+## [br]
+## Key: Test suite source file path[br]
+## Value: Array of [class GdUnitTestCase] instances
+var _discover_cache := {}
+
+
+## Tracks discovered test changes for debug purposes.[br]
+## [br]
+## Available in debug mode only. Contains dictionaries:[br]
+## - changed_tests: Tests that were moved or renamed[br]
+## - deleted_tests: Tests that were removed[br]
+## - added_tests: New tests that were discovered
+var _discovered_changes := {}
+
+
+## Controls test change debug tracking.[br]
+## [br]
+## When true, maintains _discovered_changes for debugging.[br]
+## Used primarily in tests to verify change detection.
+var _is_debug := false
+
+
+## Creates a new guard instance.[br]
+## [br]
+## [param is_debug] When true, enables change tracking for debugging.
+func _init(is_debug := false) -> void:
+ _is_debug = is_debug
+ # Register for discovery events to sync the cache
+ @warning_ignore("return_value_discarded")
+ GdUnitSignals.instance().gdunit_test_discover_added.connect(sync_test_added)
+ GdUnitSignals.instance().gdunit_test_discover_deleted.connect(sync_test_deleted)
+ GdUnitSignals.instance().gdunit_test_discover_modified.connect(sync_test_modified)
+ GdUnitSignals.instance().gdunit_event.connect(handle_discover_events)
+
+
+## Adds a discovered test to the cache.[br]
+## [br]
+## [param test_case] The test case to add to the cache.
+func sync_test_added(test_case: GdUnitTestCase) -> void:
+ var test_cases: Array[GdUnitTestCase] = _discover_cache.get_or_add(test_case.source_file, Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase))
+ test_cases.append(test_case)
+
+
+## Removes a test from the cache.[br]
+## [br]
+## [param test_case] The test case to remove from the cache.
+func sync_test_deleted(test_case: GdUnitTestCase) -> void:
+ var test_cases: Array[GdUnitTestCase] = _discover_cache.get_or_add(test_case.source_file, Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase))
+ test_cases.erase(test_case)
+
+
+## Updates a test from the cache.[br]
+## [br]
+## [param test_case] The test case to update from the cache.
+func sync_test_modified(changed_test: GdUnitTestCase) -> void:
+ var test_cases: Array[GdUnitTestCase] = _discover_cache.get_or_add(changed_test.source_file, Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase))
+ for test in test_cases:
+ if test.guid == changed_test.guid:
+ test.test_name = changed_test.test_name
+ test.display_name = changed_test.display_name
+ test.line_number = changed_test.line_number
+ break
+
+
+## Handles test discovery events.[br]
+## [br]
+## Resets the cache when a new discovery starts.[br]
+## [param event] The discovery event to handle.
+func handle_discover_events(event: GdUnitEvent) -> void:
+ # reset the cache on fresh discovery
+ if event.type() == GdUnitEvent.DISCOVER_START:
+ _discover_cache = {}
+
+
+## Registers a callback for discovered tests.[br]
+## [br]
+## Default sink writes to [class GdUnitTestDiscoverSink].
+static func default_discover_sink(test_case: GdUnitTestCase) -> void:
+ GdUnitTestDiscoverSink.discover(test_case)
+
+
+## Finds a test case by its unique identifier.[br]
+## [br]
+## Searches through all cached test cases across all test suites[br]
+## to find a test with the matching GUID.[br]
+## [br]
+## [param id] The GUID of the test to find[br]
+## Returns the matching test case or null if not found.
+func find_test_by_id(id: GdUnitGUID) -> GdUnitTestCase:
+ for test_sets: Array[GdUnitTestCase] in _discover_cache.values():
+ for test in test_sets:
+ if test.guid.equals(id):
+ return test
+
+ return null
+
+
+func get_discovered_tests() -> Array[GdUnitTestCase]:
+ var discovered_tests: Array[GdUnitTestCase] = []
+ for test_sets: Array[GdUnitTestCase] in _discover_cache.values():
+ discovered_tests.append_array(test_sets)
+ return discovered_tests
+
+
+## Discovers tests in a script and tracks changes.[br]
+## [br]
+## Handles both GDScript and C# test suites.[br]
+## The guard maintains test identity through changes.[br]
+## [br]
+## [param script] The test script to analyze[br]
+## [param discover_sink] Optional callback for test discovery events
+func discover(script: Script, discover_sink: Callable = default_discover_sink) -> void:
+ # Verify the script has no errors before run test discovery
+ var result := script.reload(true)
+ if result != OK:
+ return
+
+ if _is_debug:
+ _discovered_changes["changed_tests"] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)
+ _discovered_changes["deleted_tests"] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)
+ _discovered_changes["added_tests"] = Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)
+
+ if GdUnitTestSuiteScanner.is_test_suite(script):
+ # for cs scripts we need to recomplie before discover new tests
+ if script.get_class() == "CSharpScript":
+ await rebuild_project(script)
+
+ # rediscover all tests
+ var source_file := script.resource_path
+ var discovered_tests: Array[GdUnitTestCase] = []
+
+ GdUnitTestDiscoverer.discover_tests(script, func(test_case: GdUnitTestCase) -> void:
+ discovered_tests.append(test_case)
+ )
+
+ # The suite is never discovered, we add all discovered tests
+ if not _discover_cache.has(source_file):
+ for test_case in discovered_tests:
+ discover_sink.call(test_case)
+ return
+
+ sync_moved_tests(source_file, discovered_tests)
+ sync_renamed_tests(source_file, discovered_tests)
+ sync_deleted_tests(source_file, discovered_tests)
+ sync_added_tests(source_file, discovered_tests, discover_sink)
+
+
+## Synchronizes moved tests between discover cycles.[br]
+## [br]
+## A test is considered moved when:[br]
+## - It has the same name[br]
+## - But a different line number[br]
+## [br]
+## [param source_file] suite source path[br]
+## [param discovered_tests] Newly discovered tests
+func sync_moved_tests(source_file: String, discovered_tests: Array[GdUnitTestCase]) -> void:
+ @warning_ignore("unsafe_method_access")
+ var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate()
+ for discovered_test in discovered_tests:
+ # lookup in cache
+ var original_tests: Array[GdUnitTestCase] = cache.filter(is_test_moved.bind(discovered_test))
+ for test in original_tests:
+ # update the line_number
+ var line_number_before := test.line_number
+ test.line_number = discovered_test.line_number
+ GdUnitSignals.instance().gdunit_test_discover_modified.emit(test)
+ if _is_debug:
+ prints("-> moved test id:%s %s: line:(%d -> %d)" % [test.guid, test.display_name, line_number_before, test.line_number])
+ @warning_ignore("unsafe_method_access")
+ _discovered_changes.get_or_add("changed_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test)
+
+
+## Synchronizes renamed tests between discover cycles.[br]
+## [br]
+## A test is considered renamed when:[br]
+## - It has the same line number[br]
+## - But a different name[br]
+## [br]
+## [param source_file] suite source path[br]
+## [param discovered_tests] Newly discovered tests
+func sync_renamed_tests(source_file: String, discovered_tests: Array[GdUnitTestCase]) -> void:
+ @warning_ignore("unsafe_method_access")
+ var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate()
+ for discovered_test in discovered_tests:
+ # lookup in cache
+ var original_tests: Array[GdUnitTestCase] = cache.filter(is_test_renamed.bind(discovered_test))
+ for test in original_tests:
+ # update the renaming names
+ var original_display_name := test.display_name
+ test.test_name = discovered_test.test_name
+ test.display_name = discovered_test.display_name
+ GdUnitSignals.instance().gdunit_test_discover_modified.emit(test)
+ if _is_debug:
+ prints("-> renamed test id:%s %s -> %s" % [test.guid, original_display_name, test.display_name])
+ @warning_ignore("unsafe_method_access")
+ _discovered_changes.get_or_add("changed_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test)
+
+
+## Synchronizes deleted tests between discover cycles.[br]
+## [br]
+## A test is considered deleted when:[br]
+## - It exists in the cache[br]
+## - But is not found in the newly discovered tests[br]
+## [br]
+## [param source_file] suite source path[br]
+## [param discovered_tests] Newly discovered tests
+func sync_deleted_tests(source_file: String, discovered_tests: Array[GdUnitTestCase]) -> void:
+ @warning_ignore("unsafe_method_access")
+ var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate()
+ # lookup in cache
+ for test in cache:
+ if not discovered_tests.any(test_equals.bind(test)):
+ GdUnitSignals.instance().gdunit_test_discover_deleted.emit(test)
+ if _is_debug:
+ prints("-> deleted test id:%s %s:%d" % [test.guid, test.display_name, test.line_number])
+ @warning_ignore("unsafe_method_access")
+ _discovered_changes.get_or_add("deleted_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test)
+
+
+## Synchronizes newly added tests between discover cycles.[br]
+## [br]
+## A test is considered added when:[br]
+## - It exists in the newly discovered tests[br]
+## - But is not found in the cache[br]
+## [br]
+## [param source_file] suite source path[br]
+## [param discovered_tests] Newly discovered tests[br]
+## [param discover_sink] Callback to handle newly discovered tests
+func sync_added_tests(source_file: String, discovered_tests: Array[GdUnitTestCase], discover_sink: Callable) -> void:
+ @warning_ignore("unsafe_method_access")
+ var cache: Array[GdUnitTestCase] = _discover_cache.get(source_file).duplicate()
+ # lookup in cache
+ for test in discovered_tests:
+ if not cache.any(test_equals.bind(test)):
+ discover_sink.call(test)
+ if _is_debug:
+ prints("-> added test id:%s %s:%d" % [test.guid, test.display_name, test.line_number])
+ @warning_ignore("unsafe_method_access")
+ _discovered_changes.get_or_add("added_tests", Array([], TYPE_OBJECT, "RefCounted", GdUnitTestCase)).append(test)
+
+
+func is_test_renamed(left: GdUnitTestCase, right: GdUnitTestCase) -> bool:
+ return left.line_number == right.line_number and left.test_name != right.test_name
+
+
+func is_test_moved(left: GdUnitTestCase, right: GdUnitTestCase) -> bool:
+ return left.line_number != right.line_number and left.test_name == right.test_name
+
+
+func test_equals(left: GdUnitTestCase, right: GdUnitTestCase) -> bool:
+ return left.display_name == right.display_name
+
+
+# do rebuild the entire project, there is actual no way to enforce the Godot engine itself to do this
+func rebuild_project(script: Script) -> void:
+ var class_path := ProjectSettings.globalize_path(script.resource_path)
+ print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard: CSharpScript change detected on: '%s' [/color]" % class_path)
+ var scene_tree := Engine.get_main_loop() as SceneTree
+ await scene_tree.process_frame
+
+ var output := []
+ var exit_code := OS.execute("dotnet", ["--version"], output)
+ if exit_code == -1:
+ print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=RED]Rebuild the project failed.[/color]")
+ print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=RED]Can't find installed `dotnet`! Please check your environment is setup correctly.[/color]")
+ return
+
+ print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=DEEP_SKY_BLUE]Found dotnet v%s[/color]" % str(output[0]).strip_edges())
+ output.clear()
+
+ exit_code = OS.execute("dotnet", ["build"], output)
+ print_rich("[color=CORNFLOWER_BLUE]GdUnitTestDiscoverGuard:[/color] [color=DEEP_SKY_BLUE]Rebuild the project ... [/color]")
+ for out: String in output:
+ print_rich("[color=DEEP_SKY_BLUE] %s" % out.strip_edges())
+ await scene_tree.process_frame
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid
new file mode 100644
index 0000000..0d8b757
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverGuard.gd.uid
@@ -0,0 +1 @@
+uid://dnyw8fm7vfc6i
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd
new file mode 100644
index 0000000..5d0e5b6
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd
@@ -0,0 +1,13 @@
+## A static utility class that acts as a central sink for test case discovery events in GdUnit4.
+## Instead of implementing custom sink classes, test discovery consumers should connect to
+## the GdUnitSignals.gdunit_test_discovered signal to receive test case discoveries.
+## This design allows for a more flexible and decoupled test discovery system.
+class_name GdUnitTestDiscoverSink
+extends RefCounted
+
+
+## Emits a discovered test case through the GdUnitSignals system.[br]
+## Sends the test case to all listeners connected to the gdunit_test_discovered signal.[br]
+## [member test_case] The discovered test case to be broadcast to all connected listeners.
+static func discover(test_case: GdUnitTestCase) -> void:
+ GdUnitSignals.instance().gdunit_test_discover_added.emit(test_case)
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid
new file mode 100644
index 0000000..841f240
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverSink.gd.uid
@@ -0,0 +1 @@
+uid://jf2a2cjlqkkr
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd
new file mode 100644
index 0000000..cb3e407
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd
@@ -0,0 +1,171 @@
+class_name GdUnitTestDiscoverer
+extends RefCounted
+
+
+static func run() -> Array[GdUnitTestCase]:
+ console_log("Running test discovery ..")
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new())
+
+ # We run the test discovery in an extra thread so that the main thread is not blocked
+ var t:= Thread.new()
+ @warning_ignore("return_value_discarded")
+ t.start(func () -> Array[GdUnitTestCase]:
+ # Loading previous test session
+ var runner_config := GdUnitRunnerConfig.new()
+ runner_config.load_config()
+ var recovered_tests := runner_config.test_cases()
+ var test_suite_directories := scan_all_test_directories(GdUnitSettings.test_root_folder())
+ var scanner := GdUnitTestSuiteScanner.new()
+
+ var collected_tests: Array[GdUnitTestCase] = []
+ var collected_test_suites: Array[Script] = []
+ # collect test suites
+ for test_suite_dir in test_suite_directories:
+ collected_test_suites.append_array(scanner.scan_directory(test_suite_dir))
+
+ # Do sync the main thread before emit the discovered test suites to the inspector
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ for test_suites_script in collected_test_suites:
+ discover_tests(test_suites_script, func(test_case: GdUnitTestCase) -> void:
+ # Sync test uid from last test session
+ recover_test_guid(test_case, recovered_tests)
+ collected_tests.append(test_case)
+ GdUnitTestDiscoverSink.discover(test_case)
+ )
+
+ console_log_discover_results(collected_tests)
+ if !recovered_tests.is_empty():
+ console_log("Recovered last test session successfully, %d tests restored." % recovered_tests.size(), true)
+ return collected_tests
+ )
+ # wait unblocked to the tread is finished
+ while t.is_alive():
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ # needs finally to wait for finish
+ var test_to_execute: Array[GdUnitTestCase] = await t.wait_to_finish()
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0))
+ return test_to_execute
+
+
+## Restores the last test run session by loading the test run config file and rediscover the tests
+static func restore_last_session() -> void:
+ if GdUnitSettings.is_test_discover_enabled():
+ return
+
+ var runner_config := GdUnitRunnerConfig.new()
+ var result := runner_config.load_config()
+ # Report possible config loading errors
+ if result.is_error():
+ console_log("Recovery of the last test session failed: %s" % result.error_message(), true)
+ # If no config file found, skip test recovery
+ if result.is_warn():
+ return
+
+ # If no tests recorded, skip test recovery
+ var test_cases := runner_config.test_cases()
+ if test_cases.size() == 0:
+ return
+
+ # We run the test session restoring in an extra thread so that the main thread is not blocked
+ var t:= Thread.new()
+ t.start(func () -> void:
+ # Do sync the main thread before emit the discovered test suites to the inspector
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ console_log("Recovering last test session ..", true)
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverStart.new())
+ for test_case in test_cases:
+ GdUnitTestDiscoverSink.discover(test_case)
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitEventTestDiscoverEnd.new(0, 0))
+ console_log("Recovered last test session successfully, %d tests restored." % test_cases.size(), true)
+ )
+ t.wait_to_finish()
+
+
+static func recover_test_guid(current: GdUnitTestCase, recovered_tests: Array[GdUnitTestCase]) -> void:
+ for recovered_test in recovered_tests:
+ if recovered_test.fully_qualified_name == current.fully_qualified_name:
+ current.guid = recovered_test.guid
+
+
+static func console_log_discover_results(tests: Array[GdUnitTestCase]) -> void:
+ var grouped_by_suites := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String:
+ return test.source_file
+ )
+ for suite_tests: Array in grouped_by_suites.values():
+ var test_case: GdUnitTestCase = suite_tests[0]
+ console_log("Discover: TestSuite %s with %d tests found" % [test_case.source_file, suite_tests.size()])
+ console_log("Discover tests done, %d TestSuites and total %d Tests found. " % [grouped_by_suites.size(), tests.size()])
+ console_log("")
+
+
+static func console_log(message: String, on_console := false) -> void:
+ prints(message)
+ if on_console:
+ GdUnitSignals.instance().gdunit_message.emit(message)
+
+
+static func filter_tests(method: Dictionary) -> bool:
+ var method_name: String = method["name"]
+ return method_name.begins_with("test_")
+
+
+static func default_discover_sink(test_case: GdUnitTestCase) -> void:
+ GdUnitTestDiscoverSink.discover(test_case)
+
+
+static func discover_tests(source_script: Script, discover_sink := default_discover_sink) -> void:
+ if source_script is GDScript:
+ var test_names := source_script.get_script_method_list()\
+ .filter(filter_tests)\
+ .map(func(method: Dictionary) -> String: return method["name"])
+ # no tests discovered?
+ if test_names.is_empty():
+ return
+
+ var parser := GdScriptParser.new()
+ var fds := parser.get_function_descriptors(source_script as GDScript, test_names)
+ for fd in fds:
+ var resolver := GdFunctionParameterSetResolver.new(fd)
+ for test_case in resolver.resolve_test_cases(source_script as GDScript):
+ discover_sink.call(test_case)
+ elif source_script.get_class() == "CSharpScript":
+ if not GdUnit4CSharpApiLoader.is_api_loaded():
+ return
+ for test_case in GdUnit4CSharpApiLoader.discover_tests(source_script):
+ discover_sink.call(test_case)
+
+
+static func scan_all_test_directories(root: String) -> PackedStringArray:
+ var base_directory := "res://"
+ # If the test root folder is configured as blank, "/", or "res://", use the root folder as described in the settings panel
+ if root.is_empty() or root == "/" or root == base_directory:
+ return [base_directory]
+ return scan_test_directories(base_directory, root, [])
+
+
+static func scan_test_directories(base_directory: String, test_directory: String, test_suite_paths: PackedStringArray) -> PackedStringArray:
+ print_verbose("Scannning for test directory '%s' at %s" % [test_directory, base_directory])
+ for directory in DirAccess.get_directories_at(base_directory):
+ if directory.begins_with("."):
+ continue
+ var current_directory := normalize_path(base_directory + "/" + directory)
+ if FileAccess.file_exists(current_directory + "/.gdignore"):
+ continue
+ if GdUnitTestSuiteScanner.exclude_scan_directories.has(current_directory):
+ continue
+ if match_test_directory(directory, test_directory):
+ @warning_ignore("return_value_discarded")
+ test_suite_paths.append(current_directory)
+ else:
+ @warning_ignore("return_value_discarded")
+ scan_test_directories(current_directory, test_directory, test_suite_paths)
+ return test_suite_paths
+
+
+static func normalize_path(path: String) -> String:
+ return path.replace("///", "//")
+
+
+static func match_test_directory(directory: String, test_directory: String) -> bool:
+ return directory == test_directory or test_directory.is_empty() or test_directory == "/" or test_directory == "res://"
diff --git a/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid
new file mode 100644
index 0000000..c505a79
--- /dev/null
+++ b/addons/gdUnit4/src/core/discovery/GdUnitTestDiscoverer.gd.uid
@@ -0,0 +1 @@
+uid://dr8h7h5duuybc
diff --git a/addons/gdUnit4/src/core/event/GdUnitEvent.gd b/addons/gdUnit4/src/core/event/GdUnitEvent.gd
new file mode 100644
index 0000000..fb5a3a6
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEvent.gd
@@ -0,0 +1,207 @@
+class_name GdUnitEvent
+extends Resource
+
+const WARNINGS = "warnings"
+const FAILED = "failed"
+const FLAKY = "flaky"
+const ERRORS = "errors"
+const SKIPPED = "skipped"
+const ELAPSED_TIME = "elapsed_time"
+const ORPHAN_NODES = "orphan_nodes"
+const ERROR_COUNT = "error_count"
+const FAILED_COUNT = "failed_count"
+const SKIPPED_COUNT = "skipped_count"
+const RETRY_COUNT = "retry_count"
+
+enum {
+ INIT,
+ STOP,
+ TESTSUITE_BEFORE,
+ TESTSUITE_AFTER,
+ TESTCASE_BEFORE,
+ TESTCASE_AFTER,
+ DISCOVER_START,
+ DISCOVER_END,
+ SESSION_START,
+ SESSION_CLOSE
+}
+
+var _event_type: int
+var _guid: GdUnitGUID
+var _resource_path: String
+var _suite_name: String
+var _test_name: String
+var _total_count: int = 0
+var _statistics := Dictionary()
+var _reports: Array[GdUnitReport] = []
+
+
+func suite_before(p_resource_path: String, p_suite_name: String, p_total_count: int) -> GdUnitEvent:
+ _guid = GdUnitGUID.new()
+ _event_type = TESTSUITE_BEFORE
+ _resource_path = p_resource_path
+ _suite_name = p_suite_name
+ _test_name = "before"
+ _total_count = p_total_count
+ return self
+
+
+func suite_after(p_resource_path: String, p_suite_name: String, p_statistics: Dictionary = {}, p_reports: Array[GdUnitReport] = []) -> GdUnitEvent:
+ _guid = GdUnitGUID.new()
+ _event_type = TESTSUITE_AFTER
+ _resource_path = p_resource_path
+ _suite_name = p_suite_name
+ _test_name = "after"
+ _statistics = p_statistics
+ _reports = p_reports
+ return self
+
+
+func test_before(p_guid: GdUnitGUID) -> GdUnitEvent:
+ _event_type = TESTCASE_BEFORE
+ _guid = p_guid
+ return self
+
+
+func test_after(p_guid: GdUnitGUID, name: String, p_statistics: Dictionary = {}, p_reports :Array[GdUnitReport] = []) -> GdUnitEvent:
+ _event_type = TESTCASE_AFTER
+ _guid = p_guid
+ _test_name = name
+ _statistics = p_statistics
+ _reports = p_reports
+ return self
+
+
+func type() -> int:
+ return _event_type
+
+
+func guid() -> GdUnitGUID:
+ return _guid
+
+
+func suite_name() -> String:
+ return _suite_name
+
+
+func test_name() -> String:
+ return _test_name
+
+
+func elapsed_time() -> int:
+ return _statistics.get(ELAPSED_TIME, 0)
+
+
+func orphan_nodes() -> int:
+ return _statistics.get(ORPHAN_NODES, 0)
+
+
+func statistic(p_type :String) -> int:
+ return _statistics.get(p_type, 0)
+
+
+func total_count() -> int:
+ return _total_count
+
+
+func success_count() -> int:
+ return total_count() - error_count() - failed_count() - skipped_count()
+
+
+func error_count() -> int:
+ return _statistics.get(ERROR_COUNT, 0)
+
+
+func failed_count() -> int:
+ return _statistics.get(FAILED_COUNT, 0)
+
+
+func skipped_count() -> int:
+ return _statistics.get(SKIPPED_COUNT, 0)
+
+
+func retry_count() -> int:
+ return _statistics.get(RETRY_COUNT, 0)
+
+
+func resource_path() -> String:
+ return _resource_path
+
+
+func is_success() -> bool:
+ return not is_failed() and not is_error()
+
+
+func is_warning() -> bool:
+ return _statistics.get(WARNINGS, false) or orphan_nodes() > 0
+
+
+func is_failed() -> bool:
+ return _statistics.get(FAILED, false)
+
+
+func is_error() -> bool:
+ return _statistics.get(ERRORS, false)
+
+
+func is_flaky() -> bool:
+ return _statistics.get(FLAKY, false)
+
+
+func is_skipped() -> bool:
+ return _statistics.get(SKIPPED, false)
+
+
+func reports() -> Array[GdUnitReport]:
+ return _reports
+
+
+func _to_string() -> String:
+ return "Event: %s id:%s %s:%s, %s, %s" % [_event_type, _guid, _suite_name, _test_name, _statistics, _reports]
+
+
+func serialize() -> Dictionary:
+ var serialized := {
+ "type" : _event_type,
+ "resource_path": _resource_path,
+ "suite_name" : _suite_name,
+ "test_name" : _test_name,
+ "total_count" : _total_count,
+ "statistics" : _statistics
+ }
+ if _guid != null:
+ serialized["guid"] = _guid._guid
+ serialized["reports"] = _serialize_TestReports()
+ return serialized
+
+
+func deserialize(serialized: Dictionary) -> GdUnitEvent:
+ _event_type = serialized.get("type", null)
+ _guid = GdUnitGUID.new(str(serialized.get("guid", "")))
+ _resource_path = serialized.get("resource_path", null)
+ _suite_name = serialized.get("suite_name", null)
+ _test_name = serialized.get("test_name", "unknown")
+ _total_count = serialized.get("total_count", 0)
+ _statistics = serialized.get("statistics", Dictionary())
+ if serialized.has("reports"):
+ # needs this workaround to copy typed values in the array
+ var reports_to_deserializ :Array[Dictionary] = []
+ @warning_ignore("unsafe_cast")
+ reports_to_deserializ.append_array(serialized.get("reports") as Array)
+ _reports = _deserialize_reports(reports_to_deserializ)
+ return self
+
+
+func _serialize_TestReports() -> Array[Dictionary]:
+ var serialized_reports :Array[Dictionary] = []
+ for report in _reports:
+ serialized_reports.append(report.serialize())
+ return serialized_reports
+
+
+func _deserialize_reports(p_reports: Array[Dictionary]) -> Array[GdUnitReport]:
+ var deserialized_reports :Array[GdUnitReport] = []
+ for report in p_reports:
+ var test_report := GdUnitReport.new().deserialize(report)
+ deserialized_reports.append(test_report)
+ return deserialized_reports
diff --git a/addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid
new file mode 100644
index 0000000..b01c4d3
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEvent.gd.uid
@@ -0,0 +1 @@
+uid://be80qqix7cxt8
diff --git a/addons/gdUnit4/src/core/event/GdUnitEventInit.gd b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd
new file mode 100644
index 0000000..774e7d4
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd
@@ -0,0 +1,6 @@
+class_name GdUnitInit
+extends GdUnitEvent
+
+
+func _init() -> void:
+ _event_type = INIT
diff --git a/addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid
new file mode 100644
index 0000000..a18f4e5
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEventInit.gd.uid
@@ -0,0 +1 @@
+uid://cn0cbj8cbhp32
diff --git a/addons/gdUnit4/src/core/event/GdUnitEventStop.gd b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd
new file mode 100644
index 0000000..d7a3c11
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd
@@ -0,0 +1,6 @@
+class_name GdUnitStop
+extends GdUnitEvent
+
+
+func _init() -> void:
+ _event_type = STOP
diff --git a/addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid
new file mode 100644
index 0000000..7e32a4d
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEventStop.gd.uid
@@ -0,0 +1 @@
+uid://v7dkld783gk8
diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd
new file mode 100644
index 0000000..c6194ef
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd
@@ -0,0 +1,19 @@
+class_name GdUnitEventTestDiscoverEnd
+extends GdUnitEvent
+
+
+var _total_testsuites: int
+
+
+func _init(testsuite_count: int, test_count: int) -> void:
+ _event_type = DISCOVER_END
+ _total_testsuites = testsuite_count
+ _total_count = test_count
+
+
+func total_test_suites() -> int:
+ return _total_testsuites
+
+
+func total_tests() -> int:
+ return _total_count
diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid
new file mode 100644
index 0000000..3d26683
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverEnd.gd.uid
@@ -0,0 +1 @@
+uid://cw044tcedgm0a
diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd
new file mode 100644
index 0000000..c7dd36f
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd
@@ -0,0 +1,6 @@
+class_name GdUnitEventTestDiscoverStart
+extends GdUnitEvent
+
+
+func _init() -> void:
+ _event_type = DISCOVER_START
diff --git a/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid
new file mode 100644
index 0000000..5478eb1
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitEventTestDiscoverStart.gd.uid
@@ -0,0 +1 @@
+uid://dfktvv2e63sor
diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd
new file mode 100644
index 0000000..52dab3f
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd
@@ -0,0 +1,6 @@
+class_name GdUnitSessionClose
+extends GdUnitEvent
+
+
+func _init() -> void:
+ _event_type = SESSION_CLOSE
diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid
new file mode 100644
index 0000000..cdfdae2
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitSessionClose.gd.uid
@@ -0,0 +1 @@
+uid://bj0o8343e00i0
diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd
new file mode 100644
index 0000000..420ad53
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd
@@ -0,0 +1,6 @@
+class_name GdUnitSessionStart
+extends GdUnitEvent
+
+
+func _init() -> void:
+ _event_type = SESSION_START
diff --git a/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid
new file mode 100644
index 0000000..8dc2be5
--- /dev/null
+++ b/addons/gdUnit4/src/core/event/GdUnitSessionStart.gd.uid
@@ -0,0 +1 @@
+uid://ctm8r6mjxxo18
diff --git a/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd
new file mode 100644
index 0000000..844561b
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd
@@ -0,0 +1,274 @@
+## The execution context
+## It contains all the necessary information about the executed stage, such as memory observers, reports, orphan monitor
+class_name GdUnitExecutionContext
+
+enum GC_ORPHANS_CHECK {
+ NONE,
+ SUITE_HOOK_AFTER,
+ TEST_HOOK_AFTER,
+ TEST_CASE
+}
+
+
+var _parent_context: GdUnitExecutionContext
+var _sub_context: Array[GdUnitExecutionContext] = []
+var _orphan_monitor: GdUnitOrphanNodesMonitor
+var _memory_observer: GdUnitMemoryObserver
+var _report_collector: GdUnitTestReportCollector
+var _timer: LocalTime
+var _test_case_name: StringName
+var _test_case_parameter_set: Array
+var _name: String
+var _test_execution_iteration: int = 0
+var _flaky_test_check := GdUnitSettings.is_test_flaky_check_enabled()
+var _flaky_test_retries := GdUnitSettings.get_flaky_max_retries()
+
+
+var error_monitor: GodotGdErrorMonitor = null:
+ get:
+ if _parent_context != null:
+ return _parent_context.error_monitor
+ if error_monitor == null:
+ error_monitor = GodotGdErrorMonitor.new()
+ return error_monitor
+
+
+var test_suite: GdUnitTestSuite = null:
+ get:
+ if _parent_context != null:
+ return _parent_context.test_suite
+ return test_suite
+
+
+var test_case: _TestCase = null:
+ get:
+ if test_case == null and _parent_context != null:
+ return _parent_context.test_case
+ return test_case
+
+
+func _init(name: StringName, parent_context: GdUnitExecutionContext = null) -> void:
+ _name = name
+ _parent_context = parent_context
+ _timer = LocalTime.now()
+ _orphan_monitor = GdUnitOrphanNodesMonitor.new(name)
+
+ if parent_context != null:
+ parent_context._orphan_monitor.add_child_monitor(_orphan_monitor)
+ orphan_monitor_start()
+
+ _memory_observer = GdUnitMemoryObserver.new()
+ _report_collector = GdUnitTestReportCollector.new()
+ if parent_context != null:
+ parent_context._sub_context.append(self)
+
+
+func dispose() -> void:
+ if test_suite != null:
+ test_suite.free()
+ _timer = null
+ _orphan_monitor = null
+ _report_collector = null
+ _memory_observer = null
+ _parent_context = null
+ test_suite = null
+ test_case = null
+ dispose_sub_contexts()
+
+
+func dispose_sub_contexts() -> void:
+ for context in _sub_context:
+ context.dispose()
+ _sub_context.clear()
+
+
+func terminate() -> void:
+ if test_case:
+ test_case.do_terminate()
+
+
+static func of(pe: GdUnitExecutionContext) -> GdUnitExecutionContext:
+ var context := GdUnitExecutionContext.new(pe._test_case_name, pe)
+ context._test_case_name = pe._test_case_name
+ context._test_execution_iteration = pe._test_execution_iteration
+ return context
+
+
+static func of_test_case(pe: GdUnitExecutionContext, p_test_case: _TestCase) -> GdUnitExecutionContext:
+ assert(p_test_case, "test_case is null")
+ var context := GdUnitExecutionContext.new(p_test_case.test_name(), pe)
+ context.test_case = p_test_case
+ return context
+
+
+static func of_parameterized_test(pe: GdUnitExecutionContext, test_case_name: String, test_case_parameter_set: Array) -> GdUnitExecutionContext:
+ var context := GdUnitExecutionContext.new(test_case_name, pe)
+ context._test_case_name = test_case_name
+ context._test_case_parameter_set = test_case_parameter_set
+ return context
+
+
+func get_test_suite_path() -> String:
+ return test_suite.get_script().resource_path
+
+
+func get_test_suite_name() -> StringName:
+ return test_suite.get_name()
+
+
+func get_test_case_name() -> StringName:
+ if _test_case_name.is_empty():
+ return test_case._test_case.display_name
+ return _test_case_name
+
+
+func error_monitor_start() -> void:
+ error_monitor.start()
+
+
+func error_monitor_stop() -> void:
+ error_monitor.stop()
+ for error_report in error_monitor.to_reports():
+ if error_report.is_error():
+ _report_collector.push_back(error_report)
+
+
+func orphan_monitor_start() -> void:
+ _orphan_monitor.start()
+
+
+func orphan_monitor_collect() -> void:
+ _orphan_monitor.collect()
+
+
+func orphan_monitor_stop() -> void:
+ _orphan_monitor.stop()
+
+
+func add_report(report: GdUnitReport) -> GdUnitReport:
+ _report_collector.push_back(report)
+ return report
+
+
+func reports() -> Array[GdUnitReport]:
+ return _report_collector.reports()
+
+
+func collect_reports(recursive: bool) -> Array[GdUnitReport]:
+ if not recursive:
+ return reports()
+
+ # we combine the reports of test_before(), test_after() and test() to be reported by `fire_test_ended`
+ # we strictly need to copy the reports before adding sub context reports to avoid manipulation of the current context
+ var current_reports := reports().duplicate()
+ for sub_context in _sub_context:
+ current_reports.append_array(sub_context.collect_reports(true))
+
+ return current_reports
+
+
+func calculate_statistics(reports_: Array[GdUnitReport]) -> Dictionary:
+ var failed_count := GdUnitTestReportCollector.count_failures(reports_)
+ var error_count := GdUnitTestReportCollector.count_errors(reports_)
+ var warn_count := GdUnitTestReportCollector.count_warnings(reports_)
+ var skip_count := GdUnitTestReportCollector.count_skipped(reports_)
+ var orphan_count := GdUnitTestReportCollector.count_orphans(reports_)
+ var is_failed := !is_success()
+ var elapsed_time := _timer.elapsed_since_ms()
+ var retries := 1 if _parent_context == null else _sub_context.size()
+ # Mark as flaky if it is successful, but errors were counted
+ var is_flaky := retries > 1 and not is_failed
+ # In the case of a flakiness test, we do not report an error counter, as an unreliable test is considered successful
+ # after a certain number of repetitions.
+ if is_flaky:
+ failed_count = 0
+
+ return {
+ GdUnitEvent.RETRY_COUNT: retries,
+ GdUnitEvent.ELAPSED_TIME: elapsed_time,
+ GdUnitEvent.FAILED: is_failed,
+ GdUnitEvent.ERRORS: error_count > 0,
+ GdUnitEvent.WARNINGS: warn_count > 0,
+ GdUnitEvent.FLAKY: is_flaky,
+ GdUnitEvent.SKIPPED: skip_count > 0,
+ GdUnitEvent.FAILED_COUNT: failed_count,
+ GdUnitEvent.ERROR_COUNT: error_count,
+ GdUnitEvent.SKIPPED_COUNT: skip_count,
+ GdUnitEvent.ORPHAN_NODES: orphan_count,
+ }
+
+
+func is_success() -> bool:
+ if _sub_context.is_empty():
+ return not _report_collector.has_failures()
+ # we on test suite level?
+ if _parent_context == null:
+ return not _report_collector.has_failures()
+
+ return _sub_context[-1].is_success() and not _report_collector.has_failures()
+
+
+func is_skipped() -> bool:
+ return (
+ _sub_context.any(func(c :GdUnitExecutionContext) -> bool:
+ return c.is_skipped())
+ or test_case.is_skipped() if test_case != null else false
+ )
+
+
+func is_interupted() -> bool:
+ return false if test_case == null else test_case.is_interupted()
+
+
+func sum(accum: int, number: int) -> int:
+ return accum + number
+
+
+func retry_execution() -> bool:
+ var retry := _test_execution_iteration < 1 if not _flaky_test_check else _test_execution_iteration < _flaky_test_retries
+ if retry:
+ _test_execution_iteration += 1
+ return retry
+
+
+func register_auto_free(obj: Variant) -> Variant:
+ return _memory_observer.register_auto_free(obj)
+
+
+## Runs the gdunit garbage collector to free registered object and handle orphan node reporting
+func gc(gc_orphan_check: GC_ORPHANS_CHECK = GC_ORPHANS_CHECK.NONE) -> void:
+ # unreference last used assert form the test to prevent memory leaks
+ GdUnitThreadManager.get_current_context().clear_assert()
+ await _memory_observer.gc()
+ orphan_monitor_stop()
+
+ match(gc_orphan_check):
+ GC_ORPHANS_CHECK.SUITE_HOOK_AFTER:
+ _orphan_monitor.collect()
+ var orphan_infos := _orphan_monitor.detected_orphans()
+ if orphan_infos.is_empty():
+ return
+ reports().push_front(GdUnitReport.new() \
+ .create(GdUnitReport.ORPHAN, 1, GdAssertMessages.orphan_detected_on_suite_setup(orphan_infos))
+ .with_current_value(orphan_infos.size()))
+
+ GC_ORPHANS_CHECK.TEST_HOOK_AFTER:
+ _orphan_monitor.collect()
+ var orphans := _orphan_monitor.detected_orphans()
+ if not orphans.is_empty():
+ reports().push_front(GdUnitReport.new()\
+ .create(GdUnitReport.ORPHAN, 1, GdAssertMessages.orphan_detected_on_test_setup(orphans))
+ .with_current_value(orphans.size()))
+
+ GC_ORPHANS_CHECK.TEST_CASE:
+ var orphans := _orphan_monitor.detected_orphans()
+ if orphans.is_empty():
+ var orphans_count := _orphan_monitor.orphans_count()
+ if orphans_count > 0:
+ reports().push_front(GdUnitReport.new() \
+ .create(GdUnitReport.ORPHAN, test_case.line_number(), GdAssertMessages.orphan_warning(orphans_count))
+ .with_current_value(orphans_count))
+ else:
+ reports().push_front(GdUnitReport.new()\
+ .create(GdUnitReport.ORPHAN, test_case.line_number(), GdAssertMessages.orphan_detected_on_test(orphans))
+ .with_current_value(orphans.size()))
diff --git a/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid
new file mode 100644
index 0000000..98ede87
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitExecutionContext.gd.uid
@@ -0,0 +1 @@
+uid://doxksl3xmwd4t
diff --git a/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd
new file mode 100644
index 0000000..dd03a31
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd
@@ -0,0 +1,135 @@
+## The memory watcher for objects that have been registered and are released when 'gc' is called.
+class_name GdUnitMemoryObserver
+extends RefCounted
+
+const TAG_OBSERVE_INSTANCE := "GdUnit4_observe_instance_"
+const TAG_AUTO_FREE = "GdUnit4_marked_auto_free"
+const GdUnitTools = preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+
+var _store :Array[Variant] = []
+# enable for debugging purposes
+var _is_stdout_verbose := false
+const _show_debug := false
+
+
+## Registration of an instance to be released when an execution phase is completed
+func register_auto_free(obj :Variant) -> Variant:
+ if not is_instance_valid(obj):
+ return obj
+ # do not register on GDScriptNativeClass
+ @warning_ignore("unsafe_cast")
+ if typeof(obj) == TYPE_OBJECT and (obj as Object).is_class("GDScriptNativeClass") :
+ return obj
+ #if obj is GDScript or obj is ScriptExtension:
+ # return obj
+ if obj is MainLoop:
+ push_error("GdUnit4: Avoid to add mainloop to auto_free queue %s" % obj)
+ return
+ if _is_stdout_verbose:
+ print_verbose("GdUnit4:gc():register auto_free(%s)" % obj)
+ # only register pure objects
+ if obj is GdUnitSceneRunner:
+ _store.push_back(obj)
+ else:
+ _store.append(obj)
+ _tag_object(obj)
+ return obj
+
+
+# to disable instance guard when run into issues.
+static func _is_instance_guard_enabled() -> bool:
+ return false
+
+
+static func debug_observe(name :String, obj :Object, indent :int = 0) -> void:
+ if not _show_debug:
+ return
+ var script :GDScript= obj if obj is GDScript else obj.get_script()
+ if script:
+ var base_script :GDScript = script.get_base_script()
+ @warning_ignore("unsafe_method_access")
+ prints("".lpad(indent, " "), name, obj, obj.get_class(), "reference_count:", obj.get_reference_count() if obj is RefCounted else 0, "script:", script, script.resource_path)
+ if base_script:
+ debug_observe("+", base_script, indent+1)
+ else:
+ @warning_ignore("unsafe_method_access")
+ prints(name, obj, obj.get_class(), obj.get_name())
+
+
+static func guard_instance(obj :Object) -> void:
+ if not _is_instance_guard_enabled():
+ return
+ var tag := TAG_OBSERVE_INSTANCE + str(abs(obj.get_instance_id()))
+ if Engine.has_meta(tag):
+ return
+ debug_observe("Gard on instance", obj)
+ Engine.set_meta(tag, obj)
+
+
+static func unguard_instance(obj :Object, verbose := true) -> void:
+ if not _is_instance_guard_enabled():
+ return
+ var tag := TAG_OBSERVE_INSTANCE + str(abs(obj.get_instance_id()))
+ if verbose:
+ debug_observe("unguard instance", obj)
+ if Engine.has_meta(tag):
+ Engine.remove_meta(tag)
+
+
+static func gc_guarded_instance(name :String, instance :Object) -> void:
+ if not _is_instance_guard_enabled():
+ return
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ unguard_instance(instance, false)
+ if is_instance_valid(instance) and instance is RefCounted:
+ # finally do this very hacky stuff
+ # we need to manually unreferece to avoid leaked scripts
+ # but still leaked GDScriptFunctionState exists
+ #var script :GDScript = instance.get_script()
+ #if script:
+ # var base_script :GDScript = script.get_base_script()
+ # if base_script:
+ # base_script.unreference()
+ debug_observe(name, instance)
+ (instance as RefCounted).unreference()
+ await (Engine.get_main_loop() as SceneTree).process_frame
+
+
+static func gc_on_guarded_instances() -> void:
+ if not _is_instance_guard_enabled():
+ return
+ for tag in Engine.get_meta_list():
+ if tag.begins_with(TAG_OBSERVE_INSTANCE):
+ var instance :Object = Engine.get_meta(tag)
+ await gc_guarded_instance("Leaked instance detected:", instance)
+ await GdUnitTools.free_instance(instance, false)
+
+
+# store the object into global store aswell to be verified by 'is_marked_auto_free'
+func _tag_object(obj :Variant) -> void:
+ var tagged_object: Array = Engine.get_meta(TAG_AUTO_FREE, [])
+ tagged_object.append(obj)
+ Engine.set_meta(TAG_AUTO_FREE, tagged_object)
+
+
+## Runs over all registered objects and releases them
+func gc() -> void:
+ if _store.is_empty():
+ return
+ # give engine time to free objects to process objects marked by queue_free()
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ if _is_stdout_verbose:
+ print_verbose("GdUnit4:gc():running", " freeing %d objects .." % _store.size())
+ var tagged_objects: Array = Engine.get_meta(TAG_AUTO_FREE, [])
+ while not _store.is_empty():
+ var value :Variant = _store.pop_front()
+ tagged_objects.erase(value)
+ await GdUnitTools.free_instance(value, _is_stdout_verbose)
+ assert(_store.is_empty(), "The memory observer has still entries in the store!")
+
+
+## Checks whether the specified object is registered for automatic release
+static func is_marked_auto_free(obj: Variant) -> bool:
+ var tagged_objects: Array = Engine.get_meta(TAG_AUTO_FREE, [])
+ return tagged_objects.has(obj)
diff --git a/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid
new file mode 100644
index 0000000..35465f8
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitMemoryObserver.gd.uid
@@ -0,0 +1 @@
+uid://3kyvv6qv76xy
diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd
new file mode 100644
index 0000000..91247bd
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd
@@ -0,0 +1,77 @@
+# Collects all reports seperated as warnings, failures and errors
+class_name GdUnitTestReportCollector
+extends RefCounted
+
+
+var _reports :Array[GdUnitReport] = []
+
+
+static func __filter_is_error(report :GdUnitReport) -> bool:
+ return report.is_error()
+
+
+static func __filter_is_failure(report :GdUnitReport) -> bool:
+ return report.is_failure()
+
+
+static func __filter_is_warning(report :GdUnitReport) -> bool:
+ return report.is_warning()
+
+
+static func __filter_is_skipped(report :GdUnitReport) -> bool:
+ return report.is_skipped()
+
+
+static func __filter_is_orphan(report: GdUnitReport) -> bool:
+ return report.is_orphan()
+
+
+static func count_failures(reports_: Array[GdUnitReport]) -> int:
+ return reports_.filter(__filter_is_failure).size()
+
+
+static func count_errors(reports_: Array[GdUnitReport]) -> int:
+ return reports_.filter(__filter_is_error).size()
+
+
+static func count_warnings(reports_: Array[GdUnitReport]) -> int:
+ return reports_.filter(__filter_is_warning).size()
+
+
+static func count_skipped(reports_: Array[GdUnitReport]) -> int:
+ return reports_.filter(__filter_is_skipped).size()
+
+
+static func count_orphans(reports_: Array[GdUnitReport]) -> int:
+ var orphan_reports := reports_.filter(__filter_is_orphan)
+ if orphan_reports.is_empty():
+ return 0
+ ## Collect orphan count from the reports
+ var orphans := 0
+ for report: GdUnitReport in orphan_reports:
+ orphans += report._current_value
+ return orphans
+
+
+func has_failures() -> bool:
+ return _reports.any(__filter_is_failure)
+
+
+func has_errors() -> bool:
+ return _reports.any(__filter_is_error)
+
+
+func has_warnings() -> bool:
+ return _reports.any(__filter_is_warning)
+
+
+func has_skipped() -> bool:
+ return _reports.any(__filter_is_skipped)
+
+
+func reports() -> Array[GdUnitReport]:
+ return _reports
+
+
+func push_back(report :GdUnitReport) -> void:
+ _reports.push_back(report)
diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid
new file mode 100644
index 0000000..3c1601f
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitTestReportCollector.gd.uid
@@ -0,0 +1 @@
+uid://5dmb52xvknlt
diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd
new file mode 100644
index 0000000..dfb4441
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd
@@ -0,0 +1,56 @@
+## The executor to run a test-suite
+class_name GdUnitTestSuiteExecutor
+
+
+# preload all asserts here
+@warning_ignore("unused_private_class_variable")
+var _assertions := GdUnitAssertions.new()
+var _executeStage := GdUnitTestSuiteExecutionStage.new()
+var _debug_mode : bool
+var _terminated := false
+
+func _init(debug_mode: bool = false) -> void:
+ _executeStage.set_debug_mode(debug_mode)
+ _debug_mode = debug_mode
+ GdUnitSignals.instance().gdunit_test_session_terminate.connect(_on_testsession_terminated)
+
+
+func _on_testsession_terminated() -> void:
+ _terminated = true
+ GdUnitThreadManager.interrupt()
+
+
+func run_and_wait(tests: Array[GdUnitTestCase]) -> void:
+ if !_debug_mode:
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitInit.new())
+
+ var orphan_detection_enabled := GdUnitSettings.is_verbose_orphans()
+ if not orphan_detection_enabled:
+ prints("!!! Reporting orphan nodes is disabled. Please check GdUnit settings.")
+
+ # first we group all tests by resource path
+ var grouped_by_suites := GdArrayTools.group_by(tests, func(test: GdUnitTestCase) -> String:
+ return test.suite_resource_path
+ )
+ var scanner := GdUnitTestSuiteScanner.new()
+ for suite_path: String in grouped_by_suites.keys():
+ if _terminated:
+ break
+ @warning_ignore("unsafe_call_argument")
+ var suite_tests: Array[GdUnitTestCase] = Array(grouped_by_suites[suite_path], TYPE_OBJECT, "RefCounted", GdUnitTestCase)
+ var script := GdUnitTestSuiteScanner.load_with_disabled_warnings(suite_path)
+ if script.get_class() == "GDScript":
+ var context := GdUnitExecutionContext.new(suite_path)
+ var test_suite := scanner.load_suite(script as GDScript, suite_tests)
+ context.test_suite = test_suite
+ (Engine.get_main_loop() as SceneTree).root.add_child(test_suite)
+ await _executeStage.execute(context)
+ context.dispose()
+ else:
+ await GdUnit4CSharpApiLoader.execute(suite_tests)
+ if !_debug_mode:
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitStop.new())
+
+
+func fail_fast(enabled :bool) -> void:
+ _executeStage.fail_fast(enabled)
diff --git a/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid
new file mode 100644
index 0000000..6717eba
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/GdUnitTestSuiteExecutor.gd.uid
@@ -0,0 +1 @@
+uid://cfluc4qh1cia6
diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd
new file mode 100644
index 0000000..f106f21
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd
@@ -0,0 +1,22 @@
+## The test case shutdown hook implementation.[br]
+## It executes the 'test_after()' block from the test-suite.
+class_name GdUnitTestCaseAfterStage
+extends IGdUnitExecutionStage
+
+
+var _call_stage: bool
+
+
+func _init(call_stage := true) -> void:
+ _call_stage = call_stage
+
+
+func _execute(context: GdUnitExecutionContext) -> void:
+ var test_suite := context.test_suite
+
+ if _call_stage:
+ @warning_ignore("redundant_await")
+ await test_suite.after_test()
+
+ await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_HOOK_AFTER)
+ context.error_monitor_stop()
diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid
new file mode 100644
index 0000000..f241c9f
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseAfterStage.gd.uid
@@ -0,0 +1 @@
+uid://cry2ikys4ww2x
diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd
new file mode 100644
index 0000000..d7790a9
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd
@@ -0,0 +1,20 @@
+## The test case startup hook implementation.[br]
+## It executes the 'test_before()' block from the test-suite.
+class_name GdUnitTestCaseBeforeStage
+extends IGdUnitExecutionStage
+
+var _call_stage :bool
+
+
+func _init(call_stage := true) -> void:
+ _call_stage = call_stage
+
+
+func _execute(context: GdUnitExecutionContext) -> void:
+ var test_suite := context.test_suite
+
+ if _call_stage:
+ @warning_ignore("redundant_await")
+ await test_suite.before_test()
+
+ context.error_monitor_start()
diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid
new file mode 100644
index 0000000..2073bc3
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseBeforeStage.gd.uid
@@ -0,0 +1 @@
+uid://lqofqnr401s3
diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd
new file mode 100644
index 0000000..473f1e2
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd
@@ -0,0 +1,37 @@
+## The test case execution stage.[br]
+class_name GdUnitTestCaseExecutionStage
+extends IGdUnitExecutionStage
+
+
+var _stage_single_test: IGdUnitExecutionStage = GdUnitTestCaseSingleExecutionStage.new()
+var _stage_fuzzer_test: IGdUnitExecutionStage = GdUnitTestCaseFuzzedExecutionStage.new()
+
+
+## Executes the test case 'test_<name>()'.[br]
+## It executes synchronized following stages[br]
+## -> test_before() [br]
+## -> test_case() [br]
+## -> test_after() [br]
+@warning_ignore("redundant_await")
+func _execute(context :GdUnitExecutionContext) -> void:
+ var test_case := context.test_case
+
+ context.error_monitor_start()
+
+ if test_case.is_fuzzed():
+ await _stage_fuzzer_test.execute(context)
+ else:
+ await _stage_single_test.execute(context)
+
+ await context.gc()
+ context.error_monitor_stop()
+
+ # finally free the test instance
+ if is_instance_valid(context.test_case):
+ context.test_case.dispose()
+
+
+func set_debug_mode(debug_mode :bool = false) -> void:
+ super.set_debug_mode(debug_mode)
+ _stage_single_test.set_debug_mode(debug_mode)
+ _stage_fuzzer_test.set_debug_mode(debug_mode)
diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid
new file mode 100644
index 0000000..7105242
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestCaseExecutionStage.gd.uid
@@ -0,0 +1 @@
+uid://bnx6cbvnp5pr1
diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd
new file mode 100644
index 0000000..03bbd0f
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd
@@ -0,0 +1,29 @@
+## The test suite shutdown hook implementation.[br]
+## It executes the 'after()' block from the test-suite.
+class_name GdUnitTestSuiteAfterStage
+extends IGdUnitExecutionStage
+
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+
+func _execute(context :GdUnitExecutionContext) -> void:
+ var test_suite := context.test_suite
+
+ @warning_ignore("redundant_await")
+ await test_suite.after()
+ await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.SUITE_HOOK_AFTER)
+
+ var reports := context.collect_reports(false)
+ var statistics := context.calculate_statistics(reports)
+ fire_event(GdUnitEvent.new()\
+ .suite_after(context.get_test_suite_path(),\
+ test_suite.get_name(),
+ statistics,
+ reports))
+ GdUnitFileAccess.clear_tmp()
+ # Guard that checks if all doubled (spy/mock) objects are released
+ await GdUnitClassDoubler.check_leaked_instances()
+ # we hide the scene/main window after runner is finished
+ if not Engine.is_embedded_in_editor():
+ DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid
new file mode 100644
index 0000000..1b19c2a
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteAfterStage.gd.uid
@@ -0,0 +1 @@
+uid://mwtvw184h4nk
diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd
new file mode 100644
index 0000000..a6ed8c5
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd
@@ -0,0 +1,14 @@
+## The test suite startup hook implementation.[br]
+## It executes the 'before()' block from the test-suite.
+class_name GdUnitTestSuiteBeforeStage
+extends IGdUnitExecutionStage
+
+
+func _execute(context: GdUnitExecutionContext) -> void:
+ var test_suite := context.test_suite
+
+ fire_event(GdUnitEvent.new()\
+ .suite_before(context.get_test_suite_path(), test_suite.get_name(), test_suite.get_child_count()))
+
+ @warning_ignore("redundant_await")
+ await test_suite.before()
diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid
new file mode 100644
index 0000000..2fdfdc3
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteBeforeStage.gd.uid
@@ -0,0 +1 @@
+uid://fx62snxk32g4
diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd
new file mode 100644
index 0000000..07e3779
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd
@@ -0,0 +1,147 @@
+## The test suite main execution stage.[br]
+class_name GdUnitTestSuiteExecutionStage
+extends IGdUnitExecutionStage
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+var _stage_before :IGdUnitExecutionStage = GdUnitTestSuiteBeforeStage.new()
+var _stage_after :IGdUnitExecutionStage = GdUnitTestSuiteAfterStage.new()
+var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseExecutionStage.new()
+var _fail_fast := false
+
+
+## Executes all tests of an test suite.[br]
+## It executes synchronized following stages[br]
+## -> before() [br]
+## -> run all test cases [br]
+## -> after() [br]
+func _execute(context :GdUnitExecutionContext) -> void:
+ if context.test_suite.__is_skipped:
+ await fire_test_suite_skipped(context)
+ else:
+ @warning_ignore("return_value_discarded")
+ GdUnitMemoryObserver.guard_instance(context.test_suite.__awaiter)
+ await _stage_before.execute(context)
+ for test_case_index in context.test_suite.get_child_count():
+ # iterate only over test cases
+ var test_case := context.test_suite.get_child(test_case_index) as _TestCase
+ if not is_instance_valid(test_case):
+ continue
+ context.test_suite.set_active_test_case(test_case.test_name())
+ var test_case_context := GdUnitExecutionContext.of_test_case(context, test_case)
+ await _stage_test.execute(test_case_context)
+ # stop on first error or if fail fast is enabled
+ if test_case.is_terminated() or (_fail_fast and not test_case_context.is_success()):
+ break
+ if test_case.is_interupted():
+ # it needs to go this hard way to kill the outstanding awaits of a test case when the test timed out
+ # we delete the current test suite where is execute the current test case to kill the function state
+ # and replace it by a clone without function state
+ context.test_suite = await clone_test_suite(context.test_suite)
+ await _stage_after.execute(context)
+ GdUnitMemoryObserver.unguard_instance(context.test_suite.__awaiter)
+
+ await (Engine.get_main_loop() as SceneTree).process_frame
+
+
+# clones a test suite and moves the test cases to new instance
+func clone_test_suite(test_suite :GdUnitTestSuite) -> GdUnitTestSuite:
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ dispose_timers(test_suite)
+ await GdUnitMemoryObserver.gc_guarded_instance("Manually free on awaiter", test_suite.__awaiter)
+ var parent := test_suite.get_parent()
+ var _test_suite := GdUnitTestSuite.new()
+ parent.remove_child(test_suite)
+ copy_properties(test_suite, _test_suite)
+ for child in test_suite.get_children():
+ test_suite.remove_child(child)
+ _test_suite.add_child(child)
+ parent.add_child(_test_suite)
+ @warning_ignore("return_value_discarded")
+ GdUnitMemoryObserver.guard_instance(_test_suite.__awaiter)
+ # finally free current test suite instance
+ test_suite.free()
+ await (Engine.get_main_loop() as SceneTree).process_frame
+ return _test_suite
+
+
+func dispose_timers(test_suite :GdUnitTestSuite) -> void:
+ GdUnitTools.release_timers()
+ for child in test_suite.get_children():
+ if child is Timer:
+ (child as Timer).stop()
+ test_suite.remove_child(child)
+ child.free()
+
+
+func copy_properties(source :Object, target :Object) -> void:
+ if not source is _TestCase and not source is GdUnitTestSuite:
+ return
+ for property in source.get_property_list():
+ var property_name :String = property["name"]
+ if property_name == "__awaiter":
+ continue
+ target.set(property_name, source.get(property_name))
+
+
+func fire_test_suite_skipped(context :GdUnitExecutionContext) -> void:
+ var test_suite := context.test_suite
+ var skip_count := test_suite.get_child_count()
+ fire_event(GdUnitEvent.new()\
+ .suite_before(context.get_test_suite_path(), test_suite.get_name(), skip_count))
+
+
+ for test_case_index in context.test_suite.get_child_count():
+ # iterate only over test cases
+ var test_case := context.test_suite.get_child(test_case_index) as _TestCase
+ if not is_instance_valid(test_case):
+ continue
+ var test_case_context := GdUnitExecutionContext.of_test_case(context, test_case)
+ fire_event(GdUnitEvent.new().test_before(test_case.id()))
+ # use skip count 0 because we counted it over the complete test suite
+ fire_test_skipped(test_case_context, 0)
+
+
+ var statistics := {
+ GdUnitEvent.ORPHAN_NODES: 0,
+ GdUnitEvent.ELAPSED_TIME: 0,
+ GdUnitEvent.WARNINGS: false,
+ GdUnitEvent.ERRORS: false,
+ GdUnitEvent.ERROR_COUNT: 0,
+ GdUnitEvent.FAILED: false,
+ GdUnitEvent.FAILED_COUNT: 0,
+ GdUnitEvent.SKIPPED_COUNT: skip_count,
+ GdUnitEvent.SKIPPED: true
+ }
+ var report := GdUnitReport.new().create(GdUnitReport.SKIPPED, -1, GdAssertMessages.test_suite_skipped(test_suite.__skip_reason, skip_count))
+ fire_event(GdUnitEvent.new().suite_after(context.get_test_suite_path(), test_suite.get_name(), statistics, [report]))
+ await (Engine.get_main_loop() as SceneTree).process_frame
+
+
+func fire_test_skipped(context: GdUnitExecutionContext, skip_count := 1) -> void:
+ var test_case := context.test_case
+ var statistics := {
+ GdUnitEvent.ORPHAN_NODES: 0,
+ GdUnitEvent.ELAPSED_TIME: 0,
+ GdUnitEvent.WARNINGS: false,
+ GdUnitEvent.ERRORS: false,
+ GdUnitEvent.ERROR_COUNT: 0,
+ GdUnitEvent.FAILED: false,
+ GdUnitEvent.FAILED_COUNT: 0,
+ GdUnitEvent.SKIPPED: true,
+ GdUnitEvent.SKIPPED_COUNT: skip_count,
+ }
+ var report := GdUnitReport.new() \
+ .create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped("Skipped from the entire test suite"))
+ fire_event(GdUnitEvent.new().test_after(test_case.id(), test_case.test_name(), statistics, [report]))
+
+
+func set_debug_mode(debug_mode :bool = false) -> void:
+ super.set_debug_mode(debug_mode)
+ _stage_before.set_debug_mode(debug_mode)
+ _stage_after.set_debug_mode(debug_mode)
+ _stage_test.set_debug_mode(debug_mode)
+
+
+func fail_fast(enabled :bool) -> void:
+ _fail_fast = enabled
diff --git a/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid
new file mode 100644
index 0000000..f250acb
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/GdUnitTestSuiteExecutionStage.gd.uid
@@ -0,0 +1 @@
+uid://c00afkws3ewfx
diff --git a/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd
new file mode 100644
index 0000000..a04f7e7
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd
@@ -0,0 +1,37 @@
+## The interface of execution stage.[br]
+## An execution stage is defined as an encapsulated task that can execute 1-n substages covered by its own execution context.[br]
+## Execution stage are always called synchronously.
+@abstract class_name IGdUnitExecutionStage
+extends RefCounted
+
+var _debug_mode := false
+
+
+## Executes synchronized the implemented stage in its own execution context.[br]
+## example:[br]
+## [codeblock]
+## # waits for 100ms
+## await MyExecutionStage.new().execute(<GdUnitExecutionContext>)
+## [/codeblock][br]
+func execute(context: GdUnitExecutionContext) -> void:
+ GdUnitThreadManager.get_current_context().set_execution_context(context)
+ @warning_ignore("redundant_await")
+ await _execute(context)
+
+
+## Sends the event to registered listeners
+func fire_event(event: GdUnitEvent) -> void:
+ if _debug_mode:
+ GdUnitSignals.instance().gdunit_event_debug.emit(event)
+ else:
+ GdUnitSignals.instance().gdunit_event.emit(event)
+
+
+## Internal testing stuff.[br]
+## Sets the executor into debug mode to emit `GdUnitEvent` via signal `gdunit_event_debug`
+func set_debug_mode(debug_mode: bool) -> void:
+ _debug_mode = debug_mode
+
+
+## The execution phase to be carried out.
+@abstract func _execute(context: GdUnitExecutionContext) -> void
diff --git a/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid
new file mode 100644
index 0000000..5ae6f69
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/IGdUnitExecutionStage.gd.uid
@@ -0,0 +1 @@
+uid://bfvoxsa3oad2k
diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd
new file mode 100644
index 0000000..c1e0924
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd
@@ -0,0 +1,52 @@
+## The test case execution stage.[br]
+class_name GdUnitTestCaseFuzzedExecutionStage
+extends IGdUnitExecutionStage
+
+var _stage_before :IGdUnitExecutionStage = GdUnitTestCaseBeforeStage.new(false)
+var _stage_after :IGdUnitExecutionStage = GdUnitTestCaseAfterStage.new(false)
+var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseFuzzedTestStage.new()
+
+
+func _execute(context :GdUnitExecutionContext) -> void:
+ fire_event(GdUnitEvent.new().test_before(context.test_case.id()))
+
+ while context.retry_execution():
+ var test_context := GdUnitExecutionContext.of(context)
+ await _stage_before.execute(test_context)
+ if not context.test_case.is_skipped():
+ await _stage_test.execute(GdUnitExecutionContext.of(test_context))
+ await _stage_after.execute(test_context)
+ if test_context.is_success() or test_context.is_skipped() or test_context.is_interupted():
+ break
+
+ context.gc()
+ if context.is_skipped():
+ fire_test_skipped(context)
+ else:
+ var reports: = context.collect_reports(true)
+ var statistics := context.calculate_statistics(reports)
+ fire_event(GdUnitEvent.new().test_after(context.test_case.id(), context.test_case.test_name(), statistics, reports))
+
+func set_debug_mode(debug_mode :bool = false) -> void:
+ super.set_debug_mode(debug_mode)
+ _stage_before.set_debug_mode(debug_mode)
+ _stage_after.set_debug_mode(debug_mode)
+ _stage_test.set_debug_mode(debug_mode)
+
+
+func fire_test_skipped(context: GdUnitExecutionContext) -> void:
+ var test_case := context.test_case
+ var statistics := {
+ GdUnitEvent.ORPHAN_NODES: 0,
+ GdUnitEvent.ELAPSED_TIME: 0,
+ GdUnitEvent.WARNINGS: false,
+ GdUnitEvent.ERRORS: false,
+ GdUnitEvent.ERROR_COUNT: 0,
+ GdUnitEvent.FAILED: false,
+ GdUnitEvent.FAILED_COUNT: 0,
+ GdUnitEvent.SKIPPED: true,
+ GdUnitEvent.SKIPPED_COUNT: 1,
+ }
+ var report := GdUnitReport.new() \
+ .create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped(test_case.skip_info()))
+ fire_event(GdUnitEvent.new().test_after(test_case.id(), test_case.test_name(), statistics, [report]))
diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid
new file mode 100644
index 0000000..7444e55
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedExecutionStage.gd.uid
@@ -0,0 +1 @@
+uid://buqq7wmo7x6de
diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd
new file mode 100644
index 0000000..62dda37
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd
@@ -0,0 +1,55 @@
+## The fuzzed test case execution stage.[br]
+class_name GdUnitTestCaseFuzzedTestStage
+extends IGdUnitExecutionStage
+
+var _expression_runner := GdUnitExpressionRunner.new()
+
+
+## Executes a test case with given fuzzers 'test_<name>(<fuzzer>)' iterative.[br]
+## It executes synchronized following stages[br]
+## -> test_case() [br]
+func _execute(context :GdUnitExecutionContext) -> void:
+ var test_suite := context.test_suite
+ var test_case := context.test_case
+ var fuzzers := create_fuzzers(test_suite, test_case)
+
+ # guard on fuzzers
+ for fuzzer in fuzzers:
+ @warning_ignore("return_value_discarded")
+ GdUnitMemoryObserver.guard_instance(fuzzer)
+
+ for iteration in test_case.iterations():
+ @warning_ignore("redundant_await")
+ await test_suite.before_test()
+ await test_case.execute(fuzzers, iteration)
+ @warning_ignore("redundant_await")
+ await test_suite.after_test()
+ if test_case.is_interupted():
+ break
+ # interrupt at first failure
+ var reports := context.reports()
+ if not reports.is_empty():
+ var report :GdUnitReport = reports.pop_front()
+ reports.append(GdUnitReport.new() \
+ .create(GdUnitReport.FAILURE, report.line_number(), GdAssertMessages.fuzzer_interuped(iteration, report.message())))
+ break
+ await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_CASE)
+
+ # unguard on fuzzers
+ if not test_case.is_interupted():
+ for fuzzer in fuzzers:
+ GdUnitMemoryObserver.unguard_instance(fuzzer)
+
+
+func create_fuzzers(test_suite :GdUnitTestSuite, test_case :_TestCase) -> Array[Fuzzer]:
+ if not test_case.is_fuzzed():
+ return Array()
+ test_case.generate_seed()
+ var fuzzers :Array[Fuzzer] = []
+ for fuzzer_arg in test_case.fuzzer_arguments():
+ @warning_ignore("unsafe_cast")
+ var fuzzer := _expression_runner.to_fuzzer(test_suite.get_script() as GDScript, fuzzer_arg.plain_value() as String)
+ fuzzer._iteration_index = 0
+ fuzzer._iteration_limit = test_case.iterations()
+ fuzzers.append(fuzzer)
+ return fuzzers
diff --git a/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid
new file mode 100644
index 0000000..5df0ddb
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/fuzzed/GdUnitTestCaseFuzzedTestStage.gd.uid
@@ -0,0 +1 @@
+uid://b3tpfwvmfjkoq
diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd
new file mode 100644
index 0000000..03f8fa5
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd
@@ -0,0 +1,53 @@
+## The test case execution stage.[br]
+class_name GdUnitTestCaseSingleExecutionStage
+extends IGdUnitExecutionStage
+
+
+var _stage_before :IGdUnitExecutionStage = GdUnitTestCaseBeforeStage.new()
+var _stage_after :IGdUnitExecutionStage = GdUnitTestCaseAfterStage.new()
+var _stage_test :IGdUnitExecutionStage = GdUnitTestCaseSingleTestStage.new()
+
+
+func _execute(context :GdUnitExecutionContext) -> void:
+ fire_event(GdUnitEvent.new().test_before(context.test_case.id()))
+ while context.retry_execution():
+ var test_context := GdUnitExecutionContext.of(context)
+ await _stage_before.execute(test_context)
+ if not test_context.is_skipped():
+ await _stage_test.execute(GdUnitExecutionContext.of(test_context))
+ await _stage_after.execute(test_context)
+ if test_context.is_success() or test_context.is_skipped() or test_context.is_interupted():
+ break
+
+ context.gc()
+ if context.is_skipped():
+ fire_test_skipped(context)
+ else:
+ var reports: = context.collect_reports(true)
+ var statistics := context.calculate_statistics(reports)
+ fire_event(GdUnitEvent.new().test_after(context.test_case.id(), context.test_case.test_name(), statistics, reports))
+
+
+func set_debug_mode(debug_mode :bool = false) -> void:
+ super.set_debug_mode(debug_mode)
+ _stage_before.set_debug_mode(debug_mode)
+ _stage_after.set_debug_mode(debug_mode)
+ _stage_test.set_debug_mode(debug_mode)
+
+
+func fire_test_skipped(context: GdUnitExecutionContext) -> void:
+ var test_case := context.test_case
+ var statistics := {
+ GdUnitEvent.ORPHAN_NODES: 0,
+ GdUnitEvent.ELAPSED_TIME: 0,
+ GdUnitEvent.WARNINGS: false,
+ GdUnitEvent.ERRORS: false,
+ GdUnitEvent.ERROR_COUNT: 0,
+ GdUnitEvent.FAILED: false,
+ GdUnitEvent.FAILED_COUNT: 0,
+ GdUnitEvent.SKIPPED: true,
+ GdUnitEvent.SKIPPED_COUNT: 1,
+ }
+ var report := GdUnitReport.new() \
+ .create(GdUnitReport.SKIPPED, test_case.line_number(), GdAssertMessages.test_skipped(test_case.skip_info()))
+ fire_event(GdUnitEvent.new().test_after(test_case.id(), test_case.test_name(), statistics, [report]))
diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid
new file mode 100644
index 0000000..9a0459d
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleExecutionStage.gd.uid
@@ -0,0 +1 @@
+uid://ctjx74gak008y
diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd
new file mode 100644
index 0000000..b8ace50
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd
@@ -0,0 +1,11 @@
+## The single test case execution stage.[br]
+class_name GdUnitTestCaseSingleTestStage
+extends IGdUnitExecutionStage
+
+
+## Executes a single test case 'test_<name>()'.[br]
+## It executes synchronized following stages[br]
+## -> test_case() [br]
+func _execute(context: GdUnitExecutionContext) -> void:
+ await context.test_case.execute()
+ await context.gc(GdUnitExecutionContext.GC_ORPHANS_CHECK.TEST_CASE)
diff --git a/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid
new file mode 100644
index 0000000..48f2c97
--- /dev/null
+++ b/addons/gdUnit4/src/core/execution/stages/single/GdUnitTestCaseSingleTestStage.gd.uid
@@ -0,0 +1 @@
+uid://66t5ejuybo1s
diff --git a/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd
new file mode 100644
index 0000000..0f87ad1
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd
@@ -0,0 +1,78 @@
+class_name GdUnitBaseReporterTestSessionHook
+extends GdUnitTestSessionHook
+
+
+var test_session: GdUnitTestSession:
+ get:
+ return test_session
+ set(value):
+ # disconnect first possible connected listener
+ if test_session != null:
+ test_session.test_event.disconnect(_on_test_event)
+ # add listening to current session
+ test_session = value
+ if test_session != null:
+ test_session.test_event.connect(_on_test_event)
+
+
+var _report_summary: GdUnitReportSummary
+var _reporter: GdUnitTestReporter
+var _report_writer: GdUnitReportWriter
+var _report_converter: Callable
+
+func _init(report_writer: GdUnitReportWriter, hook_name: String, hook_description: String, report_converter: Callable) -> void:
+ super(hook_name, hook_description)
+ _reporter = GdUnitTestReporter.new()
+ _report_writer = report_writer
+ _report_converter = report_converter
+
+
+func startup(session: GdUnitTestSession) -> GdUnitResult:
+ test_session = session
+ _report_summary = GdUnitReportSummary.new(_report_converter)
+ _reporter.init_summary()
+
+ return GdUnitResult.success()
+
+
+func shutdown(session: GdUnitTestSession) -> GdUnitResult:
+ var report_path := _report_writer.write(session.report_path, _report_summary)
+ session.send_message("Open {0} Report at: file://{1}".format([_report_writer.output_format(), report_path]))
+
+ return GdUnitResult.success()
+
+
+func _on_test_event(event: GdUnitEvent) -> void:
+ match event.type():
+ GdUnitEvent.TESTSUITE_BEFORE:
+ _reporter.init_statistics()
+ _report_summary.add_testsuite_report(event.resource_path(), event.suite_name(), event.total_count())
+ GdUnitEvent.TESTSUITE_AFTER:
+ var statistics := _reporter.build_test_suite_statisitcs(event)
+ _report_summary.update_testsuite_counters(
+ event.resource_path(),
+ _reporter.error_count(statistics),
+ _reporter.failed_count(statistics),
+ _reporter.orphan_nodes(statistics),
+ _reporter.skipped_count(statistics),
+ _reporter.flaky_count(statistics),
+ event.elapsed_time())
+ _report_summary.add_testsuite_reports(
+ event.resource_path(),
+ event.reports()
+ )
+ GdUnitEvent.TESTCASE_BEFORE:
+ var test := test_session.find_test_by_id(event.guid())
+ _report_summary.add_testcase(test.source_file, test.suite_name, test.display_name)
+ GdUnitEvent.TESTCASE_AFTER:
+ _reporter.add_test_statistics(event)
+ var test := test_session.find_test_by_id(event.guid())
+ _report_summary.set_counters(test.source_file,
+ test.display_name,
+ event.error_count(),
+ event.failed_count(),
+ event.orphan_nodes(),
+ event.is_skipped(),
+ event.is_flaky(),
+ event.elapsed_time())
+ _report_summary.add_reports(test.source_file, test.display_name, event.reports())
diff --git a/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid
new file mode 100644
index 0000000..94a488c
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitBaseReporterTestSessionHook.gd.uid
@@ -0,0 +1 @@
+uid://dba8xduvhy4hd
diff --git a/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd
new file mode 100644
index 0000000..4b8f390
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd
@@ -0,0 +1,9 @@
+class_name GdUnitHtmlReporterTestSessionHook
+extends GdUnitBaseReporterTestSessionHook
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+
+func _init() -> void:
+ super(GdUnitHtmlReportWriter.new(), "GdUnitHtmlTestReporter", "The Html test reporting hook.", GdUnitTools.richtext_normalize)
+ set_meta("SYSTEM_HOOK", true)
diff --git a/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid
new file mode 100644
index 0000000..6af94d5
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitHtmlReporterTestSessionHook.gd.uid
@@ -0,0 +1 @@
+uid://dsvmfyc8bxff8
diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd
new file mode 100644
index 0000000..23850e4
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd
@@ -0,0 +1,111 @@
+## @since GdUnit4 5.1.0
+##
+## Base class for creating custom test session hooks in GdUnit4.[br]
+## [br]
+## [i]Test session hooks allow users to extend the GdUnit4 test framework by providing
+## custom functionality that runs at specific points during the test execution lifecycle.
+## This base class defines the interface that all test session hooks must implement.[/i]
+## [br]
+## [br]
+## [b][u]Usage[/u][/b][br]
+## 1. Create a new class that extends GdUnitTestSessionHook[br]
+## 2. Override the required methods (startup, shutdown)[br]
+## 3. Register your hook with the test engine (using the GdUnit4 settings dialog)[br]
+## [br]
+## [b][u]Example[/u][/b]
+## [codeblock]
+## class_name MyCustomTestHook
+## extends GdUnitTestSessionHook
+##
+## func _init():
+## super("MyHook", "This is a description")
+##
+## func startup(session: GdUnitTestSession) -> GdUnitResult:
+## session.send_message("Custom hook initialized")
+## # Initialize resources, setup test environment, etc.
+## return GdUnitResult.success()
+##
+## func shutdown(session: GdUnitTestSession) -> GdUnitResult:
+## session.send_message("Custom hook cleanup completed")
+## # Cleanup resources, generate reports, etc.
+## return GdUnitResult.success()
+## [/codeblock]
+##
+## [b][u]Hook Lifecycle[/u][/b][br]
+## 1. [i][b]Registration[/b][/i]: Hooks are registered with the test engine via settings dialog[br]
+## 2. [i][b]Priority Sorting[/b][/i]: Hooks are sorted by priority[br]
+## 3. [i][b]Startup[/b][/i]: startup() is called before test execution begins, if it returns an error is shown in the console[br]
+## 4. [i][b]Test Execution[/b][/i]: Tests run normally (only if all hooks started successfully)[br]
+## 5. [i][b]Shutdown[/b][/i]: shutdown() is called after all tests complete, regardless of startup success[br]
+## [br]
+## [b][u]Priority System[/u][/b][br]
+## The priority system allows controlling the execution order of multiple hooks.[br]
+## - The order can be changed in the GdUnit4 settings dialog.[br]
+## - The priority of system hooks cannot be changed and they cannot be deleted.[br]
+## [br]
+## [b][u]Session Access[/u][/b][br]
+##
+## Both [i]startup()[/i] and [i]shutdown()[/i] methods receive a [GdUnitTestSession] parameter that provides:[br]
+## - Access to test cases being executed[br]
+## - Event emission capabilities for test progress tracking[br]
+## - Message sending functionality for logging and communication[br]
+class_name GdUnitTestSessionHook
+extends RefCounted
+
+
+## The display name of this hook.
+var name: String:
+ get:
+ return name
+
+
+## A detailed description of what this hook does.
+var description: String:
+ get:
+ return description
+
+
+## Initializes a new test session hook.
+##
+## [param _name] The display name for this hook
+## [param _description] A detailed description of the hook's functionality
+func _init(_name: String, _description: String) -> void:
+ self.name = _name
+ self.description = _description
+
+
+## Called when the test session starts up, before any tests are executed.[br]
+## [br]
+## [color=yellow][i]This method should be overridden to implement custom initialization logic[/i][/color][br]
+## [br]
+## such as:[br]
+## - Setting up test databases or external services[br]
+## - Initializing mock objects or test fixtures[br]
+## - Configuring logging or reporting systems[br]
+## - Preparing the test environment[br]
+## - Subscribing to test events via the session[br]
+## [br]
+## [param session] The test session instance providing access to test data and communication[br]
+## [b]return:[/b] [code]GdUnitResult.success()[/code] if initialization succeeds, or [code]GdUnitResult.error("error")[/code] with
+## an error message if initialization fails.
+func startup(_session: GdUnitTestSession) -> GdUnitResult:
+ return GdUnitResult.error("%s:startup is not implemented" % get_script().resource_path)
+
+
+## Called when the test session shuts down, after all tests have completed.[br]
+## [br]
+## [color=yellow][i]This method should be overridden to implement custom cleanup logic[/i][/color][br]
+## [br]
+## such as:[br]
+## - Cleaning up test databases or external services[br]
+## - Generating test reports or artifacts[br]
+## - Releasing resources allocated during startup[br]
+## - Performing final validation or assertions[br]
+## - Processing collected test events and data[br]
+## [br]
+## [param session] The test session instance providing access to test results and communication[br]
+## [b]return:[/b] [code]GdUnitResult.success()[/code] if cleanup succeeds, or [code]GdUnitResult.error("error")[/code] with
+## an error message if cleanup fails. Cleanup errors are typically logged
+## but don't prevent the test engine from shutting down.
+func shutdown(_session: GdUnitTestSession) -> GdUnitResult:
+ return GdUnitResult.error("%s:shutdown is not implemented" % get_script().resource_path)
diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid
new file mode 100644
index 0000000..719a67e
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHook.gd.uid
@@ -0,0 +1 @@
+uid://cd4t5sc3jv2i7
diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd
new file mode 100644
index 0000000..5a9bdcb
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd
@@ -0,0 +1,191 @@
+class_name GdUnitTestSessionHookService
+extends Object
+
+
+var enigne_hooks: Array[GdUnitTestSessionHook] = []:
+ get:
+ return enigne_hooks
+ set(value):
+ enigne_hooks.append(value)
+
+
+var _save_settings: bool = false
+
+
+static func instance() -> GdUnitTestSessionHookService:
+ return GdUnitSingleton.instance("GdUnitTestSessionHookService", func()->GdUnitTestSessionHookService:
+ GdUnitSignals.instance().gdunit_message.emit("Installing GdUnit4 session system hooks.")
+ var service := GdUnitTestSessionHookService.new()
+ # Register default system hooks here
+ service._save_settings = false
+ service.register(GdUnitHtmlReporterTestSessionHook.new())
+ service.register(GdUnitXMLReporterTestSessionHook.new())
+ service.load_hook_settings()
+ service._save_settings = true
+ return service
+ )
+
+
+static func contains_hook(current: GdUnitTestSessionHook, other: GdUnitTestSessionHook) -> bool:
+ return current.get_script().resource_path == other.get_script().resource_path
+
+
+func find_custom(hook: GdUnitTestSessionHook) -> int:
+ for index in enigne_hooks.size():
+ if contains_hook.call(enigne_hooks[index], hook):
+ return index
+ return -1
+
+
+func load_hook(hook_resourc_path: String) -> GdUnitResult:
+ if !FileAccess.file_exists(hook_resourc_path):
+ return GdUnitResult.error("The hook '%s' not exists." % hook_resourc_path)
+ var script: GDScript = load(hook_resourc_path)
+ if script.get_base_script() != GdUnitTestSessionHook:
+ return GdUnitResult.error("The hook '%s' must inhertit from 'GdUnitTestSessionHook'." % hook_resourc_path)
+
+ return GdUnitResult.success(script.new())
+
+
+func enable_hook(hook: GdUnitTestSessionHook, enabled: bool) -> void:
+ _enable_hook(hook, enabled)
+ GdUnitSignals.instance().gdunit_message.emit("Session hook '{name}' {enabled}.".format({
+ "name": hook.name,
+ "enabled": "enabled" if enabled else "disabled"})
+ )
+ save_hock_setttings()
+
+
+func register(hook: GdUnitTestSessionHook, enabled: bool = true) -> GdUnitResult:
+ if find_custom(hook) != -1:
+ return GdUnitResult.error("A hook instance of '%s' is already registered." % hook.get_script().resource_path)
+
+ _enable_hook(hook, enabled)
+ enigne_hooks.append(hook)
+ save_hock_setttings()
+ GdUnitSignals.instance().gdunit_message.emit("Session hook '%s' installed." % hook.name)
+
+ return GdUnitResult.success()
+
+
+func unregister(hook: GdUnitTestSessionHook) -> GdUnitResult:
+ var hook_index := find_custom(hook)
+ if hook_index == -1:
+ return GdUnitResult.error("The hook instance of '%s' is NOT registered." % hook.get_script().resource_path)
+
+ enigne_hooks.remove_at(hook_index)
+ save_hock_setttings()
+ return GdUnitResult.success()
+
+
+func move_before(hook: GdUnitTestSessionHook, before: GdUnitTestSessionHook) -> void:
+ var before_index := find_custom(before)
+ var hook_index := find_custom(hook)
+
+ # Verify the hook to move is behind the hook to be moved
+ if before_index >= hook_index:
+ return
+
+ enigne_hooks.remove_at(hook_index)
+ enigne_hooks.insert(before_index, hook)
+ save_hock_setttings()
+
+
+func move_after(hook: GdUnitTestSessionHook, after: GdUnitTestSessionHook) -> void:
+ var after_index := find_custom(after)
+ var hook_index := find_custom(hook)
+
+ # Verify the hook to move is before the hook to be moved
+ if after_index <= hook_index:
+ return
+
+ enigne_hooks.remove_at(hook_index)
+ enigne_hooks.insert(after_index, hook)
+ save_hock_setttings()
+
+
+func execute_startup(session: GdUnitTestSession) -> GdUnitResult:
+ return await execute("startup", session)
+
+
+func execute_shutdown(session: GdUnitTestSession) -> GdUnitResult:
+ return await execute("shutdown", session, true)
+
+
+func execute(hook_func: String, session: GdUnitTestSession, reverse := false) -> GdUnitResult:
+ var failed_hook_calls: Array[GdUnitResult] = []
+
+ for hook_index in enigne_hooks.size():
+ var index := enigne_hooks.size()-hook_index-1 if reverse else hook_index
+ var hook: = enigne_hooks[index]
+ if not is_enabled(hook):
+ continue
+ if OS.is_stdout_verbose():
+ GdUnitSignals.instance().gdunit_message.emit("Session hook '%s' > %s()" % [hook.name, hook_func])
+ var result: GdUnitResult = await hook.call(hook_func, session)
+ if result == null:
+ failed_hook_calls.push_back(GdUnitResult.error("Result is null! Check '%s'" % hook.get_script().resource_path))
+ elif result.is_error():
+ failed_hook_calls.push_back(result)
+
+ if failed_hook_calls.is_empty():
+ return GdUnitResult.success()
+
+ var errors := failed_hook_calls.map(func(result: GdUnitResult) -> String:
+ return "Hook call '%s' failed with error: '%s'" % [hook_func, result.error_message()]
+ )
+ return GdUnitResult.error( "\n".join(errors))
+
+
+func save_hock_setttings() -> void:
+ if not _save_settings:
+ return
+
+ var hooks_to_save: Dictionary[String, bool] = {}
+ for hook in enigne_hooks:
+ var enabled: bool = hook.get_meta("enabled")
+ hooks_to_save[hook.get_script().resource_path] = enabled
+
+ GdUnitSettings.set_session_hooks(hooks_to_save)
+
+
+func load_hook_settings() -> void:
+ var hooks_resource_paths := GdUnitSettings.get_session_hooks()
+ if hooks_resource_paths.is_empty():
+ return
+
+ for hock_path: String in hooks_resource_paths.keys():
+ var enabled := hooks_resource_paths[hock_path]
+
+ # Do not reinstall already installed hooks
+ var existing_hook: GdUnitTestSessionHook = enigne_hooks.filter(func(element: GdUnitTestSessionHook) -> bool:
+ return element.get_script().resource_path == hock_path
+ ).front()
+ # Applay enabled settings
+ if existing_hook != null:
+ _enable_hook(existing_hook, enabled)
+ continue
+
+ # Load additional hooks
+ var result := load_hook(hock_path)
+ if result.is_error():
+ push_error(result.error_message())
+ continue
+
+ GdUnitSignals.instance().gdunit_message.emit("Installing GdUnit4 session hooks.")
+ var hook: GdUnitTestSessionHook = result.value()
+
+ result = register(hook, enabled)
+ if result.is_error():
+ push_error(result.error_message())
+ continue
+
+
+static func is_enabled(hook: GdUnitTestSessionHook) -> bool:
+ if hook.has_meta("enabled"):
+ return hook.get_meta("enabled")
+ return true
+
+
+func _enable_hook(hook: GdUnitTestSessionHook, enabled: bool) -> void:
+ hook.set_meta("enabled", enabled)
diff --git a/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid
new file mode 100644
index 0000000..690538d
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitTestSessionHookService.gd.uid
@@ -0,0 +1 @@
+uid://bp5t2wb0depb6
diff --git a/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd
new file mode 100644
index 0000000..94caef5
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd
@@ -0,0 +1,11 @@
+class_name GdUnitXMLReporterTestSessionHook
+extends GdUnitBaseReporterTestSessionHook
+
+
+func _init() -> void:
+ super(JUnitXmlReportWriter.new(), "GdUnitXMLTestReporter", "The JUnit XML test reporting hook.", convert_report_message)
+ set_meta("SYSTEM_HOOK", true)
+
+
+func convert_report_message(value: String) -> String:
+ return value
diff --git a/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid
new file mode 100644
index 0000000..42d6bff
--- /dev/null
+++ b/addons/gdUnit4/src/core/hooks/GdUnitXMLReporterTestSessionHook.gd.uid
@@ -0,0 +1 @@
+uid://btuob2k5s80l
diff --git a/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd
new file mode 100644
index 0000000..fc83742
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd
@@ -0,0 +1,25 @@
+class_name GdClassDescriptor
+extends RefCounted
+
+
+var _name :String
+var _is_inner_class :bool
+var _functions :Array[GdFunctionDescriptor]
+
+
+func _init(p_name :String, p_is_inner_class :bool, p_functions :Array[GdFunctionDescriptor]) -> void:
+ _name = p_name
+ _is_inner_class = p_is_inner_class
+ _functions = p_functions
+
+
+func name() -> String:
+ return _name
+
+
+func is_inner_class() -> bool:
+ return _is_inner_class
+
+
+func functions() -> Array[GdFunctionDescriptor]:
+ return _functions
diff --git a/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid
new file mode 100644
index 0000000..759729e
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdClassDescriptor.gd.uid
@@ -0,0 +1 @@
+uid://baic1omvtg71r
diff --git a/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd
new file mode 100644
index 0000000..f1b2244
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd
@@ -0,0 +1,290 @@
+# holds all decodings for default values
+class_name GdDefaultValueDecoder
+extends GdUnitSingleton
+
+
+@warning_ignore("unused_parameter")
+var _decoders := {
+ TYPE_NIL: func(value :Variant) -> String: return "null",
+ TYPE_STRING: func(value :Variant) -> String: return '"%s"' % value,
+ TYPE_STRING_NAME: _on_type_StringName,
+ TYPE_BOOL: func(value :Variant) -> String: return str(value).to_lower(),
+ TYPE_FLOAT: func(value :Variant) -> String: return '%f' % value,
+ TYPE_COLOR: _on_type_Color,
+ TYPE_ARRAY: _on_type_Array.bind(TYPE_ARRAY),
+ TYPE_PACKED_BYTE_ARRAY: _on_type_Array.bind(TYPE_PACKED_BYTE_ARRAY),
+ TYPE_PACKED_STRING_ARRAY: _on_type_Array.bind(TYPE_PACKED_STRING_ARRAY),
+ TYPE_PACKED_FLOAT32_ARRAY: _on_type_Array.bind(TYPE_PACKED_FLOAT32_ARRAY),
+ TYPE_PACKED_FLOAT64_ARRAY: _on_type_Array.bind(TYPE_PACKED_FLOAT64_ARRAY),
+ TYPE_PACKED_INT32_ARRAY: _on_type_Array.bind(TYPE_PACKED_INT32_ARRAY),
+ TYPE_PACKED_INT64_ARRAY: _on_type_Array.bind(TYPE_PACKED_INT64_ARRAY),
+ TYPE_PACKED_COLOR_ARRAY: _on_type_Array.bind(TYPE_PACKED_COLOR_ARRAY),
+ TYPE_PACKED_VECTOR2_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR2_ARRAY),
+ TYPE_PACKED_VECTOR3_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR3_ARRAY),
+ TYPE_PACKED_VECTOR4_ARRAY: _on_type_Array.bind(TYPE_PACKED_VECTOR4_ARRAY),
+ TYPE_DICTIONARY: _on_type_Dictionary,
+ TYPE_RID: _on_type_RID,
+ TYPE_NODE_PATH: _on_type_NodePath,
+ TYPE_VECTOR2: _on_type_Vector.bind(TYPE_VECTOR2),
+ TYPE_VECTOR2I: _on_type_Vector.bind(TYPE_VECTOR2I),
+ TYPE_VECTOR3: _on_type_Vector.bind(TYPE_VECTOR3),
+ TYPE_VECTOR3I: _on_type_Vector.bind(TYPE_VECTOR3I),
+ TYPE_VECTOR4: _on_type_Vector.bind(TYPE_VECTOR4),
+ TYPE_VECTOR4I: _on_type_Vector.bind(TYPE_VECTOR4I),
+ TYPE_RECT2: _on_type_Rect2,
+ TYPE_RECT2I: _on_type_Rect2i,
+ TYPE_PLANE: _on_type_Plane,
+ TYPE_QUATERNION: _on_type_Quaternion,
+ TYPE_AABB: _on_type_AABB,
+ TYPE_BASIS: _on_type_Basis,
+ TYPE_CALLABLE: _on_type_Callable,
+ TYPE_SIGNAL: _on_type_Signal,
+ TYPE_TRANSFORM2D: _on_type_Transform2D,
+ TYPE_TRANSFORM3D: _on_type_Transform3D,
+ TYPE_PROJECTION: _on_type_Projection,
+ TYPE_OBJECT: _on_type_Object
+}
+
+static func _regex(pattern: String) -> RegEx:
+ var regex := RegEx.new()
+ var err := regex.compile(pattern)
+ if err != OK:
+ push_error("error '%s' checked pattern '%s'" % [err, pattern])
+ return null
+ return regex
+
+
+func get_decoder(type: int) -> Callable:
+ return _decoders.get(type, func(value :Variant) -> String: return '%s' % value)
+
+
+func _on_type_StringName(value: StringName) -> String:
+ if value.is_empty():
+ return 'StringName()'
+ return 'StringName("%s")' % value
+
+
+func _on_type_Object(value: Variant, _type: int) -> String:
+ return str(value)
+
+
+func _on_type_Color(color: Color) -> String:
+ if color == Color.BLACK:
+ return "Color()"
+ return "Color%s" % color
+
+
+func _on_type_NodePath(path: NodePath) -> String:
+ if path.is_empty():
+ return 'NodePath()'
+ return 'NodePath("%s")' % path
+
+
+func _on_type_Callable(_cb: Callable) -> String:
+ return 'Callable()'
+
+
+func _on_type_Signal(_s: Signal) -> String:
+ return 'Signal()'
+
+
+func _on_type_Dictionary(dict: Dictionary) -> String:
+ if dict.is_empty():
+ return '{}'
+ return str(dict)
+
+
+func _on_type_Array(value: Variant, type: int) -> String:
+ match type:
+ TYPE_ARRAY:
+ return str(value)
+
+ TYPE_PACKED_COLOR_ARRAY:
+ var colors := PackedStringArray()
+ for color: Color in value:
+ @warning_ignore("return_value_discarded")
+ colors.append(_on_type_Color(color))
+ if colors.is_empty():
+ return "PackedColorArray()"
+ return "PackedColorArray([%s])" % ", ".join(colors)
+
+ TYPE_PACKED_VECTOR2_ARRAY:
+ var vectors := PackedStringArray()
+ for vector: Vector2 in value:
+ @warning_ignore("return_value_discarded")
+ vectors.append(_on_type_Vector(vector, TYPE_VECTOR2))
+ if vectors.is_empty():
+ return "PackedVector2Array()"
+ return "PackedVector2Array([%s])" % ", ".join(vectors)
+
+ TYPE_PACKED_VECTOR3_ARRAY:
+ var vectors := PackedStringArray()
+ for vector: Vector3 in value:
+ @warning_ignore("return_value_discarded")
+ vectors.append(_on_type_Vector(vector, TYPE_VECTOR3))
+ if vectors.is_empty():
+ return "PackedVector3Array()"
+ return "PackedVector3Array([%s])" % ", ".join(vectors)
+
+ TYPE_PACKED_VECTOR4_ARRAY:
+ var vectors := PackedStringArray()
+ for vector: Vector4 in value:
+ @warning_ignore("return_value_discarded")
+ vectors.append(_on_type_Vector(vector, TYPE_VECTOR4))
+ if vectors.is_empty():
+ return "PackedVector4Array()"
+ return "PackedVector4Array([%s])" % ", ".join(vectors)
+
+ TYPE_PACKED_STRING_ARRAY:
+ var values := PackedStringArray()
+ for v: String in value:
+ @warning_ignore("return_value_discarded")
+ values.append('"%s"' % v)
+ if values.is_empty():
+ return "PackedStringArray()"
+ return "PackedStringArray([%s])" % ", ".join(values)
+
+ TYPE_PACKED_BYTE_ARRAY,\
+ TYPE_PACKED_FLOAT32_ARRAY,\
+ TYPE_PACKED_FLOAT64_ARRAY,\
+ TYPE_PACKED_INT32_ARRAY,\
+ TYPE_PACKED_INT64_ARRAY:
+ var vectors := PackedStringArray()
+ for vector: Variant in value:
+ @warning_ignore("return_value_discarded")
+ vectors.append(str(vector))
+ if vectors.is_empty():
+ return GdObjects.type_as_string(type) + "()"
+ return "%s([%s])" % [GdObjects.type_as_string(type), ", ".join(vectors)]
+ return "unknown array type %d" % type
+
+
+func _on_type_Vector(value: Variant, type: int) -> String:
+
+ if typeof(value) != type:
+ push_error("Internal Error: type missmatch detected for value '%s', expects type %s" % [value, type_string(type)])
+ return ""
+
+ match type:
+ TYPE_VECTOR2:
+ if value == Vector2():
+ return "Vector2()"
+ return "Vector2%s" % value
+ TYPE_VECTOR2I:
+ if value == Vector2i():
+ return "Vector2i()"
+ return "Vector2i%s" % value
+ TYPE_VECTOR3:
+ if value == Vector3():
+ return "Vector3()"
+ return "Vector3%s" % value
+ TYPE_VECTOR3I:
+ if value == Vector3i():
+ return "Vector3i()"
+ return "Vector3i%s" % value
+ TYPE_VECTOR4:
+ if value == Vector4():
+ return "Vector4()"
+ return "Vector4%s" % value
+ TYPE_VECTOR4I:
+ if value == Vector4i():
+ return "Vector4i()"
+ return "Vector4i%s" % value
+ return "unknown vector type %d" % type
+
+
+func _on_type_Transform2D(transform: Transform2D) -> String:
+ if transform == Transform2D():
+ return "Transform2D()"
+ return "Transform2D(Vector2%s, Vector2%s, Vector2%s)" % [transform.x, transform.y, transform.origin]
+
+
+func _on_type_Transform3D(transform: Transform3D) -> String:
+ if transform == Transform3D():
+ return "Transform3D()"
+ return "Transform3D(Vector3%s, Vector3%s, Vector3%s, Vector3%s)" % [transform.basis.x, transform.basis.y, transform.basis.z, transform.origin]
+
+
+func _on_type_Projection(projection: Projection) -> String:
+ return "Projection(Vector4%s, Vector4%s, Vector4%s, Vector4%s)" % [projection.x, projection.y, projection.z, projection.w]
+
+
+@warning_ignore("unused_parameter")
+func _on_type_RID(value: RID) -> String:
+ return "RID()"
+
+
+func _on_type_Rect2(rect: Rect2) -> String:
+ if rect == Rect2():
+ return "Rect2()"
+ return "Rect2(Vector2%s, Vector2%s)" % [rect.position, rect.size]
+
+
+func _on_type_Rect2i(rect: Variant) -> String:
+ if rect == Rect2i():
+ return "Rect2i()"
+ return "Rect2i(Vector2i%s, Vector2i%s)" % [rect.position, rect.size]
+
+
+func _on_type_Plane(plane: Plane) -> String:
+ if plane == Plane():
+ return "Plane()"
+ return "Plane(%d, %d, %d, %d)" % [plane.x, plane.y, plane.z, plane.d]
+
+
+func _on_type_Quaternion(quaternion: Quaternion) -> String:
+ if quaternion == Quaternion():
+ return "Quaternion()"
+ return "Quaternion(%d, %d, %d, %d)" % [quaternion.x, quaternion.y, quaternion.z, quaternion.w]
+
+
+func _on_type_AABB(aabb: AABB) -> String:
+ if aabb == AABB():
+ return "AABB()"
+ return "AABB(Vector3%s, Vector3%s)" % [aabb.position, aabb.size]
+
+
+func _on_type_Basis(basis: Basis) -> String:
+ if basis == Basis():
+ return "Basis()"
+ return "Basis(Vector3%s, Vector3%s, Vector3%s)" % [basis.x, basis.y, basis.z]
+
+
+static func decode(value: Variant) -> String:
+ var type := typeof(value)
+ @warning_ignore("unsafe_cast")
+ if GdArrayTools.is_type_array(type) and (value as Array).is_empty():
+ return "<empty>"
+ # For Variant types we need to determine the original type
+ if type == GdObjects.TYPE_VARIANT:
+ type = typeof(value)
+ var decoder := _get_value_decoder(type)
+ if decoder == null:
+ push_error("No value decoder registered for type '%d'! Please open a Bug issue at 'https://github.com/MikeSchulze/gdUnit4/issues/new/choose'." % type)
+ return "null"
+ if type == TYPE_OBJECT:
+ return decoder.call(value, type)
+ return decoder.call(value)
+
+
+static func decode_typed(type: int, value: Variant) -> String:
+ if value == null:
+ return "null"
+ # For Variant types we need to determine the original type
+ if type == GdObjects.TYPE_VARIANT:
+ type = typeof(value)
+ var decoder := _get_value_decoder(type)
+ if decoder == null:
+ push_error("No value decoder registered for type '%d'! Please open a Bug issue at 'https://github.com/MikeSchulze/gdUnit4/issues/new/choose'." % type)
+ return "null"
+ if type == TYPE_OBJECT:
+ return decoder.call(value, type)
+ return decoder.call(value)
+
+
+static func _get_value_decoder(type: int) -> Callable:
+ var decoder: GdDefaultValueDecoder = instance(
+ "GdUnitDefaultValueDecoders",
+ func() -> GdDefaultValueDecoder:
+ return GdDefaultValueDecoder.new())
+ return decoder.get_decoder(type)
diff --git a/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid
new file mode 100644
index 0000000..0f8e9c1
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdDefaultValueDecoder.gd.uid
@@ -0,0 +1 @@
+uid://dl0wf0ieoppil
diff --git a/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd
new file mode 100644
index 0000000..bd38eb2
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd
@@ -0,0 +1,208 @@
+class_name GdFunctionArgument
+extends RefCounted
+
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+const UNDEFINED: String = "<-NO_ARG->"
+const ARG_PARAMETERIZED_TEST := ["test_parameters", "_test_parameters"]
+
+static var _fuzzer_regex: RegEx
+static var _cleanup_leading_spaces: RegEx
+static var _fix_comma_space: RegEx
+
+var _name: String
+var _type: int
+var _type_hint: int
+var _default_value: Variant
+var _parameter_sets: PackedStringArray = []
+
+
+func _init(p_name: String, p_type: int, value: Variant = UNDEFINED, p_type_hint: int = TYPE_NIL) -> void:
+ _init_static_variables()
+ _name = p_name
+ _type = p_type
+ _type_hint = p_type_hint
+ if value != null and p_name in ARG_PARAMETERIZED_TEST:
+ _parameter_sets = _parse_parameter_set(str(value))
+ _default_value = value
+ # is argument a fuzzer?
+ if _type == TYPE_OBJECT and _fuzzer_regex.search(_name):
+ _type = GdObjects.TYPE_FUZZER
+
+
+func _init_static_variables() -> void:
+ if _fuzzer_regex == null:
+ _fuzzer_regex = GdUnitTools.to_regex("((?!(fuzzer_(seed|iterations)))fuzzer?\\w+)( ?+= ?+| ?+:= ?+| ?+:Fuzzer ?+= ?+|)")
+ _cleanup_leading_spaces = RegEx.create_from_string("(?m)^[ \t]+")
+ _fix_comma_space = RegEx.create_from_string(""", {0,}\t{0,}(?=(?:[^"]*"[^"]*")*[^"]*$)(?!\\s)""")
+
+
+func name() -> String:
+ return _name
+
+
+func default() -> Variant:
+ return type_convert(_default_value, _type)
+
+
+func set_value(value: String) -> void:
+ # we onle need to apply default values for Objects, all others are provided by the method descriptor
+ if _type == GdObjects.TYPE_FUZZER:
+ _default_value = value
+ return
+ if _name in ARG_PARAMETERIZED_TEST:
+ _parameter_sets = _parse_parameter_set(value)
+ _default_value = value
+ return
+
+ if _type == TYPE_NIL or _type == GdObjects.TYPE_VARIANT:
+ _type = _extract_value_type(value)
+ if _type == GdObjects.TYPE_VARIANT and _default_value == null:
+ _default_value = value
+ if _default_value == null:
+ match _type:
+ TYPE_DICTIONARY:
+ _default_value = as_dictionary(value)
+ TYPE_ARRAY:
+ _default_value = as_array(value)
+ GdObjects.TYPE_FUZZER:
+ _default_value = value
+ _:
+ _default_value = str_to_var(value)
+ # if converting fails assign the original value without converting
+ if _default_value == null and value != null:
+ _default_value = value
+ #prints("set default_value: ", _default_value, "with type %d" % _type, " from original: '%s'" % value)
+
+
+func _extract_value_type(value: String) -> int:
+ if value != UNDEFINED:
+ if _fuzzer_regex.search(_name):
+ return GdObjects.TYPE_FUZZER
+ if value.rfind(")") == value.length()-1:
+ return GdObjects.TYPE_FUNC
+ return _type
+
+
+func value_as_string() -> String:
+ if has_default():
+ return GdDefaultValueDecoder.decode_typed(_type, _default_value)
+ return ""
+
+
+func plain_value() -> Variant:
+ return _default_value
+
+
+func type() -> int:
+ return _type
+
+
+func type_hint() -> int:
+ return _type_hint
+
+
+func has_default() -> bool:
+ return not is_same(_default_value, UNDEFINED)
+
+
+func is_typed_array() -> bool:
+ return _type == TYPE_ARRAY and _type_hint != TYPE_NIL
+
+
+func is_parameter_set() -> bool:
+ return _name in ARG_PARAMETERIZED_TEST
+
+
+func parameter_sets() -> PackedStringArray:
+ return _parameter_sets
+
+
+static func get_parameter_set(parameters :Array[GdFunctionArgument]) -> GdFunctionArgument:
+ for current in parameters:
+ if current != null and current.is_parameter_set():
+ return current
+ return null
+
+
+func _to_string() -> String:
+ var s := _name
+ if _type != TYPE_NIL:
+ s += ": " + GdObjects.type_as_string(_type)
+ if _type_hint != TYPE_NIL:
+ s += "[%s]" % GdObjects.type_as_string(_type_hint)
+ if has_default():
+ s += "=" + value_as_string()
+ return s
+
+
+func _parse_parameter_set(input :String) -> PackedStringArray:
+ if not input.contains("["):
+ return []
+
+ input = _cleanup_leading_spaces.sub(input, "", true)
+ input = input.replace("\n", "").strip_edges().trim_prefix("[").trim_suffix("]").trim_prefix("]")
+ var single_quote := false
+ var double_quote := false
+ var array_end := 0
+ var current_index := 0
+ var output :PackedStringArray = []
+ var buf := input.to_utf8_buffer()
+ var collected_characters: = PackedByteArray()
+ var matched :bool = false
+
+ for c in buf:
+ current_index += 1
+ matched = current_index == buf.size()
+ @warning_ignore("return_value_discarded")
+ collected_characters.push_back(c)
+
+ match c:
+ # ' ': ignore spaces between array elements
+ 32: if array_end == 0 and (not double_quote and not single_quote):
+ collected_characters.remove_at(collected_characters.size()-1)
+ # ',': step over array element seperator ','
+ 44: if array_end == 0:
+ matched = true
+ collected_characters.remove_at(collected_characters.size()-1)
+ # '`':
+ 39: single_quote = !single_quote
+ # '"':
+ 34: if not single_quote: double_quote = !double_quote
+ # '['
+ 91: if not double_quote and not single_quote: array_end +=1 # counts array open
+ # ']'
+ 93: if not double_quote and not single_quote: array_end -=1 # counts array closed
+
+ # if array closed than collect the element
+ if matched:
+ var parameters := _fix_comma_space.sub(collected_characters.get_string_from_utf8(), ", ", true)
+ if not parameters.is_empty():
+ @warning_ignore("return_value_discarded")
+ output.append(parameters)
+ collected_characters.clear()
+ matched = false
+ return output
+
+
+## value converters
+
+func as_array(value: String) -> Array:
+ if value == "Array()" or value == "[]":
+ return []
+
+ if value.begins_with("Array("):
+ value = value.lstrip("Array(").rstrip(")")
+ if value.begins_with("["):
+ return str_to_var(value)
+ return []
+
+
+func as_dictionary(value: String) -> Dictionary:
+ if value == "Dictionary()":
+ return {}
+ if value.begins_with("Dictionary("):
+ value = value.lstrip("Dictionary(").rstrip(")")
+ if value.begins_with("{"):
+ return str_to_var(value)
+ return {}
diff --git a/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid
new file mode 100644
index 0000000..b98b975
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdFunctionArgument.gd.uid
@@ -0,0 +1 @@
+uid://dtwu3dpeq6huv
diff --git a/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd
new file mode 100644
index 0000000..7eae4b2
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd
@@ -0,0 +1,286 @@
+class_name GdFunctionDescriptor
+extends RefCounted
+
+var _is_virtual :bool
+var _is_static :bool
+var _is_engine :bool
+var _is_coroutine :bool
+var _name :String
+var _source_path: String
+var _line_number :int
+var _return_type :int
+var _return_class :String
+var _args : Array[GdFunctionArgument]
+var _varargs :Array[GdFunctionArgument]
+
+
+
+static func create(p_name: String, p_source_path: String, p_source_line: int, p_return_type: int, p_args: Array[GdFunctionArgument] = []) -> GdFunctionDescriptor:
+ var fd := GdFunctionDescriptor.new(p_name, p_source_line, false, false, false, p_return_type, "", p_args)
+ fd.enrich_file_info(p_source_path, p_source_line)
+ return fd
+
+static func create_static(p_name: String, p_source_path: String, p_source_line: int, p_return_type: int, p_args: Array[GdFunctionArgument] = []) -> GdFunctionDescriptor:
+ var fd := GdFunctionDescriptor.new(p_name, p_source_line, false, true, false, p_return_type, "", p_args)
+ fd.enrich_file_info(p_source_path, p_source_line)
+ return fd
+
+
+func _init(p_name :String,
+ p_line_number :int,
+ p_is_virtual :bool,
+ p_is_static :bool,
+ p_is_engine :bool,
+ p_return_type :int,
+ p_return_class :String,
+ p_args : Array[GdFunctionArgument],
+ p_varargs :Array[GdFunctionArgument] = []) -> void:
+ _name = p_name
+ _line_number = p_line_number
+ _return_type = p_return_type
+ _return_class = p_return_class
+ _is_virtual = p_is_virtual
+ _is_static = p_is_static
+ _is_engine = p_is_engine
+ _is_coroutine = false
+ _args = p_args
+ _varargs = p_varargs
+
+
+func with_return_class(clazz_name: String) -> GdFunctionDescriptor:
+ _return_class = clazz_name
+ return self
+
+
+func name() -> String:
+ return _name
+
+
+func source_path() -> String:
+ return _source_path
+
+
+func line_number() -> int:
+ return _line_number
+
+
+func is_virtual() -> bool:
+ return _is_virtual
+
+
+func is_static() -> bool:
+ return _is_static
+
+
+func is_engine() -> bool:
+ return _is_engine
+
+
+func is_vararg() -> bool:
+ return not _varargs.is_empty()
+
+
+func is_coroutine() -> bool:
+ return _is_coroutine
+
+
+func is_parameterized() -> bool:
+ for current in _args:
+ var arg :GdFunctionArgument = current
+ if arg.name() in GdFunctionArgument.ARG_PARAMETERIZED_TEST:
+ return true
+ return false
+
+
+func is_private() -> bool:
+ return name().begins_with("_") and not is_virtual()
+
+
+func return_type() -> int:
+ return _return_type
+
+
+func return_type_as_string() -> String:
+ if return_type() == TYPE_NIL:
+ return "void"
+ if (return_type() == TYPE_OBJECT or return_type() == GdObjects.TYPE_ENUM) and not _return_class.is_empty():
+ return _return_class
+ return GdObjects.type_as_string(return_type())
+
+
+func set_argument_value(arg_name: String, value: String) -> void:
+ var argument: GdFunctionArgument = _args.filter(func(arg: GdFunctionArgument) -> bool:
+ return arg.name() == arg_name
+ ).front()
+ if argument != null:
+ argument.set_value(value)
+
+
+func enrich_arguments(arguments: Array[Dictionary]) -> void:
+ for arg_index: int in arguments.size():
+ var arg: Dictionary = arguments[arg_index]
+ if arg["type"] != GdObjects.TYPE_VARARG:
+ var arg_name: String = arg["name"]
+ var arg_value: String = arg["value"]
+ set_argument_value(arg_name, arg_value)
+
+
+func enrich_file_info(p_source_path: String, p_line_number: int) -> void:
+ _source_path = p_source_path
+ _line_number = p_line_number
+
+
+func args() -> Array[GdFunctionArgument]:
+ return _args
+
+
+func varargs() -> Array[GdFunctionArgument]:
+ return _varargs
+
+
+func typed_args() -> String:
+ var collect := PackedStringArray()
+ for arg in args():
+ @warning_ignore("return_value_discarded")
+ collect.push_back(arg._to_string())
+ for arg in varargs():
+ @warning_ignore("return_value_discarded")
+ collect.push_back(arg._to_string())
+ return ", ".join(collect)
+
+
+func _to_string() -> String:
+ var fsignature := "virtual " if is_virtual() else ""
+ if _return_type == TYPE_NIL:
+ return fsignature + "[Line:%s] func %s(%s):" % [line_number(), name(), typed_args()]
+ var func_template := fsignature + "[Line:%s] func %s(%s) -> %s:"
+ if is_static():
+ func_template= "[Line:%s] static func %s(%s) -> %s:"
+ return func_template % [line_number(), name(), typed_args(), return_type_as_string()]
+
+
+# extract function description given by Object.get_method_list()
+static func extract_from(descriptor :Dictionary, is_engine_ := true) -> GdFunctionDescriptor:
+ var func_name: String = descriptor["name"]
+ var function_flags: int = descriptor["flags"]
+ var return_descriptor: Dictionary = descriptor["return"]
+ var clazz_name: String = return_descriptor["class_name"]
+ var is_virtual_: bool = function_flags & METHOD_FLAG_VIRTUAL
+ var is_static_: bool = function_flags & METHOD_FLAG_STATIC
+ var is_vararg_: bool = function_flags & METHOD_FLAG_VARARG
+
+ return GdFunctionDescriptor.new(
+ func_name,
+ -1,
+ is_virtual_,
+ is_static_,
+ is_engine_,
+ _extract_return_type(return_descriptor),
+ clazz_name,
+ _extract_args(descriptor),
+ _build_varargs(is_vararg_)
+ )
+
+# temporary exclude GlobalScope enums
+const enum_fix := [
+ "Side",
+ "Corner",
+ "Orientation",
+ "ClockDirection",
+ "HorizontalAlignment",
+ "VerticalAlignment",
+ "InlineAlignment",
+ "EulerOrder",
+ "Error",
+ "Key",
+ "MIDIMessage",
+ "MouseButton",
+ "MouseButtonMask",
+ "JoyButton",
+ "JoyAxis",
+ "PropertyHint",
+ "PropertyUsageFlags",
+ "MethodFlags",
+ "Variant.Type",
+ "Control.LayoutMode"]
+
+
+static func _extract_return_type(return_info :Dictionary) -> int:
+ var type :int = return_info["type"]
+ var usage :int = return_info["usage"]
+ if type == TYPE_INT and usage & PROPERTY_USAGE_CLASS_IS_ENUM:
+ return GdObjects.TYPE_ENUM
+ if type == TYPE_NIL and usage & PROPERTY_USAGE_NIL_IS_VARIANT:
+ return GdObjects.TYPE_VARIANT
+ if type == TYPE_NIL and usage == 6:
+ return GdObjects.TYPE_VOID
+ return type
+
+
+static func _extract_args(descriptor :Dictionary) -> Array[GdFunctionArgument]:
+ var args_ :Array[GdFunctionArgument] = []
+ var arguments :Array = descriptor["args"]
+ var defaults :Array = descriptor["default_args"]
+ # iterate backwards because the default values are stored from right to left
+ while not arguments.is_empty():
+ var arg :Dictionary = arguments.pop_back()
+ var arg_name := _argument_name(arg)
+ var arg_type := _argument_type(arg)
+ var arg_type_hint := _argument_hint(arg)
+ #var arg_class: StringName = arg["class_name"]
+ var default_value: Variant = GdFunctionArgument.UNDEFINED if defaults.is_empty() else defaults.pop_back()
+ args_.push_front(GdFunctionArgument.new(arg_name, arg_type, default_value, arg_type_hint))
+ return args_
+
+
+static func _build_varargs(p_is_vararg :bool) -> Array[GdFunctionArgument]:
+ var varargs_ :Array[GdFunctionArgument] = []
+ if not p_is_vararg:
+ return varargs_
+ varargs_.push_back(GdFunctionArgument.new("varargs", GdObjects.TYPE_VARARG, ''))
+ return varargs_
+
+
+static func _argument_name(arg :Dictionary) -> String:
+ return arg["name"]
+
+
+static func _argument_type(arg :Dictionary) -> int:
+ var type :int = arg["type"]
+ var usage :int = arg["usage"]
+
+ if type == TYPE_OBJECT:
+ if arg["class_name"] == "Node":
+ return GdObjects.TYPE_NODE
+ if arg["class_name"] == "Fuzzer":
+ return GdObjects.TYPE_FUZZER
+
+ # if the argument untyped we need to scan the assignef value type
+ if type == TYPE_NIL and usage == PROPERTY_USAGE_NIL_IS_VARIANT:
+ return GdObjects.TYPE_VARIANT
+ return type
+
+
+static func _argument_hint(arg :Dictionary) -> int:
+ var hint :int = arg["hint"]
+ var hint_string :String = arg["hint_string"]
+
+ match hint:
+ PROPERTY_HINT_ARRAY_TYPE:
+ return GdObjects.string_to_type(hint_string)
+ _:
+ return 0
+
+
+static func _argument_type_as_string(arg :Dictionary) -> String:
+ var type := _argument_type(arg)
+ match type:
+ TYPE_NIL:
+ return ""
+ TYPE_OBJECT:
+ var clazz_name :String = arg["class_name"]
+ if not clazz_name.is_empty():
+ return clazz_name
+ return ""
+ _:
+ return GdObjects.type_as_string(type)
diff --git a/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid
new file mode 100644
index 0000000..0396b95
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdFunctionDescriptor.gd.uid
@@ -0,0 +1 @@
+uid://csn6e1csw3fry
diff --git a/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd
new file mode 100644
index 0000000..9c45e62
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd
@@ -0,0 +1,188 @@
+class_name GdFunctionParameterSetResolver
+extends RefCounted
+
+const CLASS_TEMPLATE = """
+class_name _ParameterExtractor extends '${clazz_path}'
+
+func __extract_test_parameters() -> Array:
+ return ${test_params}
+
+"""
+
+const EXCLUDE_PROPERTIES_TO_COPY = [
+ "script",
+ "type",
+ "Node",
+ "_import_path"]
+
+
+var _fd: GdFunctionDescriptor
+var _static_sets_by_index := {}
+var _is_static := true
+
+func _init(fd: GdFunctionDescriptor) -> void:
+ _fd = fd
+
+
+func resolve_test_cases(script: GDScript) -> Array[GdUnitTestCase]:
+ if not is_parameterized():
+ return [GdUnitTestCase.from(script.resource_path, _fd.source_path(), _fd.line_number(), _fd.name())]
+ return extract_test_cases_by_reflection(script)
+
+
+func is_parameterized() -> bool:
+ return _fd.is_parameterized()
+
+
+func is_parameter_sets_static() -> bool:
+ return _is_static
+
+
+func is_parameter_set_static(index: int) -> bool:
+ return _is_static and _static_sets_by_index.get(index, false)
+
+
+# validates the given arguments are complete and matches to required input fields of the test function
+func validate(input_value_set: Array) -> String:
+ var input_arguments := _fd.args()
+ # check given parameter set with test case arguments
+ var expected_arg_count := input_arguments.size() - 1
+ for input_values :Variant in input_value_set:
+ var parameter_set_index := input_value_set.find(input_values)
+ if input_values is Array:
+ var arr_values: Array = input_values
+ var current_arg_count := arr_values.size()
+ if current_arg_count != expected_arg_count:
+ return "\n The parameter set at index [%d] does not match the expected input parameters!\n The test case requires [%d] input parameters, but the set contains [%d]" % [parameter_set_index, expected_arg_count, current_arg_count]
+ var error := validate_parameter_types(input_arguments, arr_values, parameter_set_index)
+ if not error.is_empty():
+ return error
+ else:
+ return "\n The parameter set at index [%d] does not match the expected input parameters!\n Expecting an array of input values." % parameter_set_index
+ return ""
+
+
+static func validate_parameter_types(input_arguments: Array, input_values: Array, parameter_set_index: int) -> String:
+ for i in input_arguments.size():
+ var input_param: GdFunctionArgument = input_arguments[i]
+ # only check the test input arguments
+ if input_param.is_parameter_set():
+ continue
+ var input_param_type := input_param.type()
+ var input_value :Variant = input_values[i]
+ var input_value_type := typeof(input_value)
+ # input parameter is not typed or is Variant we skip the type test
+ if input_param_type == TYPE_NIL or input_param_type == GdObjects.TYPE_VARIANT:
+ continue
+ # is input type enum allow int values
+ if input_param_type == GdObjects.TYPE_VARIANT and input_value_type == TYPE_INT:
+ continue
+ # allow only equal types and object == null
+ if input_param_type == TYPE_OBJECT and input_value_type == TYPE_NIL:
+ continue
+ if input_param_type != input_value_type:
+ return "\n The parameter set at index [%d] does not match the expected input parameters!\n The value '%s' does not match the required input parameter <%s>." % [parameter_set_index, input_value, input_param]
+ return ""
+
+
+func extract_test_cases_by_reflection(script: GDScript) -> Array[GdUnitTestCase]:
+ var source: Node = script.new()
+ source.queue_free()
+
+ var fa := GdFunctionArgument.get_parameter_set(_fd.args())
+ var parameter_sets := fa.parameter_sets()
+ # if no parameter set detected we need to resolve it by using reflection
+ if parameter_sets.size() == 0:
+ _is_static = false
+ return _extract_test_cases_by_reflection(source, script)
+ else:
+ var test_cases: Array[GdUnitTestCase] = []
+ var property_names := _extract_property_names(source)
+ for parameter_set_index in parameter_sets.size():
+ var parameter_set := parameter_sets[parameter_set_index]
+ _static_sets_by_index[parameter_set_index] = _is_static_parameter_set(parameter_set, property_names)
+ @warning_ignore("return_value_discarded")
+ test_cases.append(GdUnitTestCase.from(script.resource_path, _fd.source_path(), _fd.line_number(), _fd.name(), parameter_set_index, parameter_set))
+ parameter_set_index += 1
+ return test_cases
+
+
+func _extract_property_names(source: Node) -> PackedStringArray:
+ return source.get_property_list()\
+ .map(func(property :Dictionary) -> String: return property["name"])\
+ .filter(func(property :String) -> bool: return !EXCLUDE_PROPERTIES_TO_COPY.has(property))
+
+
+# tests if the test property set contains an property reference by name, if not the parameter set holds only static values
+func _is_static_parameter_set(parameters :String, property_names :PackedStringArray) -> bool:
+ for property_name in property_names:
+ if parameters.contains(property_name):
+ _is_static = false
+ return false
+ return true
+
+
+func _extract_test_cases_by_reflection(source: Node, script: GDScript) -> Array[GdUnitTestCase]:
+ var parameter_sets := load_parameter_sets(source)
+ var test_cases: Array[GdUnitTestCase] = []
+ for index in parameter_sets.size():
+ var parameter_set := str(parameter_sets[index])
+ @warning_ignore("return_value_discarded")
+ test_cases.append(GdUnitTestCase.from(script.resource_path, _fd.source_path(), _fd.line_number(), _fd.name(), index, parameter_set))
+ return test_cases
+
+
+# extracts the arguments from the given test case, using kind of reflection solution
+# to restore the parameters from a string representation to real instance type
+func load_parameter_sets(source: Node) -> Array:
+ var source_script: GDScript = source.get_script()
+ var parameter_arg := GdFunctionArgument.get_parameter_set(_fd.args())
+ var source_code := CLASS_TEMPLATE \
+ .replace("${clazz_path}", source_script.resource_path) \
+ .replace("${test_params}", parameter_arg.value_as_string())
+ var script := GDScript.new()
+ script.source_code = source_code
+ # enable this lines only for debuging
+ #script.resource_path = GdUnitFileAccess.create_temp_dir("parameter_extract") + "/%s__.gd" % test_case.get_name()
+ #DirAccess.remove_absolute(script.resource_path)
+ #ResourceSaver.save(script, script.resource_path)
+ var result := script.reload()
+ if result != OK:
+ push_error("Extracting test parameters failed! Script loading error: %s" % result)
+ return []
+ var instance: Node = script.new()
+ GdFunctionParameterSetResolver.copy_properties(source, instance)
+ instance.queue_free()
+ var parameter_sets: Array = instance.call("__extract_test_parameters")
+ return fixure_typed_parameters(parameter_sets, _fd.args())
+
+
+func fixure_typed_parameters(parameter_sets: Array, arg_descriptors: Array[GdFunctionArgument]) -> Array:
+ for parameter_set_index in parameter_sets.size():
+ var parameter_set: Array = parameter_sets[parameter_set_index]
+ # run over all function arguments
+ for parameter_index in parameter_set.size():
+ var parameter :Variant = parameter_set[parameter_index]
+ var arg_descriptor: GdFunctionArgument = arg_descriptors[parameter_index]
+ if parameter is Array:
+ var as_array: Array = parameter
+ # we need to convert the untyped array to the expected typed version
+ if arg_descriptor.is_typed_array():
+ parameter_set[parameter_index] = Array(as_array, arg_descriptor.type_hint(), "", null)
+ return parameter_sets
+
+
+static func copy_properties(source: Object, dest: Object) -> void:
+ for property in source.get_property_list():
+ var property_name :String = property["name"]
+ var property_value :Variant = source.get(property_name)
+ if EXCLUDE_PROPERTIES_TO_COPY.has(property_name):
+ continue
+ #if dest.get(property_name) == null:
+ # prints("|%s|" % property_name, source.get(property_name))
+
+ # check for invalid name property
+ if property_name == "name" and property_value == "":
+ dest.set(property_name, "<empty>");
+ continue
+ dest.set(property_name, property_value)
diff --git a/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid
new file mode 100644
index 0000000..2d86843
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdFunctionParameterSetResolver.gd.uid
@@ -0,0 +1 @@
+uid://ck3w7dlo43ky6
diff --git a/addons/gdUnit4/src/core/parse/GdScriptParser.gd b/addons/gdUnit4/src/core/parse/GdScriptParser.gd
new file mode 100644
index 0000000..a102508
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdScriptParser.gd
@@ -0,0 +1,764 @@
+class_name GdScriptParser
+extends RefCounted
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+const TYPE_VOID = GdObjects.TYPE_VOID
+const TYPE_VARIANT = GdObjects.TYPE_VARIANT
+const TYPE_VARARG = GdObjects.TYPE_VARARG
+const TYPE_FUNC = GdObjects.TYPE_FUNC
+const TYPE_FUZZER = GdObjects.TYPE_FUZZER
+const TYPE_ENUM = GdObjects.TYPE_ENUM
+
+
+var TOKEN_NOT_MATCH := Token.new("")
+var TOKEN_SPACE := SkippableToken.new(" ")
+var TOKEN_TABULATOR := SkippableToken.new("\t")
+var TOKEN_NEW_LINE := SkippableToken.new("\n")
+var TOKEN_COMMENT := SkippableToken.new("#")
+var TOKEN_CLASS_NAME := RegExToken.new("class_name", GdUnitTools.to_regex("(class_name)\\s+([\\w\\p{L}\\p{N}_]+) (extends[a-zA-Z]+:)|(class_name)\\s+([\\w\\p{L}\\p{N}_]+)"), 5)
+var TOKEN_INNER_CLASS := TokenInnerClass.new("class", GdUnitTools.to_regex("(class)\\s+(\\w\\p{L}\\p{N}_]+) (extends[a-zA-Z]+:)|(class)\\s+([\\w\\p{L}\\p{N}_]+)"), 5)
+var TOKEN_EXTENDS := RegExToken.new("extends", GdUnitTools.to_regex("extends\\s+"))
+var TOKEN_ENUM := RegExToken.new("enum", GdUnitTools.to_regex("enum\\s+"))
+var TOKEN_FUNCTION_STATIC_DECLARATION := RegExToken.new("static func", GdUnitTools.to_regex("^static\\s+func\\s+([\\w\\p{L}\\p{N}_]+)"), 1)
+var TOKEN_FUNCTION_DECLARATION := RegExToken.new("func", GdUnitTools.to_regex("^func\\s+([\\w\\p{L}\\p{N}_]+)"), 1)
+var TOKEN_FUNCTION := Token.new(".")
+var TOKEN_FUNCTION_RETURN_TYPE := Token.new("->")
+var TOKEN_FUNCTION_END := Token.new("):")
+var TOKEN_ARGUMENT_ASIGNMENT := Token.new("=")
+var TOKEN_ARGUMENT_TYPE_ASIGNMENT := Token.new(":=")
+var TOKEN_ARGUMENT_FUZZER := FuzzerToken.new(GdUnitTools.to_regex("((?!(fuzzer_(seed|iterations)))fuzzer?\\w+)( ?+= ?+| ?+:= ?+| ?+:Fuzzer ?+= ?+|)"))
+var TOKEN_ARGUMENT_TYPE := Token.new(":")
+var TOKEN_ARGUMENT_VARIADIC := Token.new("...")
+var TOKEN_ARGUMENT_SEPARATOR := Token.new(",")
+var TOKEN_BRACKET_ROUND_OPEN := Token.new("(")
+var TOKEN_BRACKET_ROUND_CLOSE := Token.new(")")
+var TOKEN_BRACKET_SQUARE_OPEN := Token.new("[")
+var TOKEN_BRACKET_SQUARE_CLOSE := Token.new("]")
+var TOKEN_BRACKET_CURLY_OPEN := Token.new("{")
+var TOKEN_BRACKET_CURLY_CLOSE := Token.new("}")
+
+
+var OPERATOR_ADD := Operator.new("+")
+var OPERATOR_SUB := Operator.new("-")
+var OPERATOR_MUL := Operator.new("*")
+var OPERATOR_DIV := Operator.new("/")
+var OPERATOR_REMAINDER := Operator.new("%")
+
+var TOKENS :Array[Token] = [
+ TOKEN_SPACE,
+ TOKEN_TABULATOR,
+ TOKEN_NEW_LINE,
+ TOKEN_COMMENT,
+ TOKEN_BRACKET_ROUND_OPEN,
+ TOKEN_BRACKET_ROUND_CLOSE,
+ TOKEN_BRACKET_SQUARE_OPEN,
+ TOKEN_BRACKET_SQUARE_CLOSE,
+ TOKEN_BRACKET_CURLY_OPEN,
+ TOKEN_BRACKET_CURLY_CLOSE,
+ TOKEN_CLASS_NAME,
+ TOKEN_INNER_CLASS,
+ TOKEN_EXTENDS,
+ TOKEN_ENUM,
+ TOKEN_FUNCTION_STATIC_DECLARATION,
+ TOKEN_FUNCTION_DECLARATION,
+ TOKEN_ARGUMENT_FUZZER,
+ TOKEN_ARGUMENT_TYPE_ASIGNMENT,
+ TOKEN_ARGUMENT_ASIGNMENT,
+ TOKEN_ARGUMENT_TYPE,
+ TOKEN_ARGUMENT_VARIADIC,
+ TOKEN_FUNCTION,
+ TOKEN_ARGUMENT_SEPARATOR,
+ TOKEN_FUNCTION_RETURN_TYPE,
+ OPERATOR_ADD,
+ OPERATOR_SUB,
+ OPERATOR_MUL,
+ OPERATOR_DIV,
+ OPERATOR_REMAINDER,
+]
+
+var _regex_strip_comments := GdUnitTools.to_regex("^([^#\"']|'[^']*'|\"[^\"]*\")*\\K#.*")
+var _scanned_inner_classes := PackedStringArray()
+var _script_constants := {}
+var _is_awaiting := GdUnitTools.to_regex("\\bawait\\s+(?![^\"]*\"[^\"]*$)(?!.*#.*await)")
+
+
+static func to_unix_format(input :String) -> String:
+ return input.replace("\r\n", "\n")
+
+
+class Token extends RefCounted:
+ var _token: String
+ var _consumed: int
+ var _is_operator: bool
+
+ func _init(p_token: String, p_is_operator := false) -> void:
+ _token = p_token
+ _is_operator = p_is_operator
+ _consumed = p_token.length()
+
+ func match(input: String, pos: int) -> bool:
+ return input.findn(_token, pos) == pos
+
+ func value() -> Variant:
+ return _token
+
+ func is_operator() -> bool:
+ return _is_operator
+
+ func is_inner_class() -> bool:
+ return _token == "class"
+
+ func is_variable() -> bool:
+ return false
+
+ func is_token(token_name :String) -> bool:
+ return _token == token_name
+
+ func is_skippable() -> bool:
+ return false
+
+ func _to_string() -> String:
+ return "Token{" + _token + "}"
+
+
+class Operator extends Token:
+ func _init(p_value: String) -> void:
+ super(p_value, true)
+
+ func _to_string() -> String:
+ return "OperatorToken{%s}" % [_token]
+
+
+# A skippable token, is just a placeholder like space or tabs
+class SkippableToken extends Token:
+
+ func _init(p_token: String) -> void:
+ super(p_token)
+
+ func is_skippable() -> bool:
+ return true
+
+
+# Token to parse function arguments
+class Variable extends Token:
+ var _plain_value :String
+ var _typed_value :Variant
+ var _type :int = TYPE_NIL
+
+
+ func _init(p_value: String) -> void:
+ super(p_value)
+ _type = _scan_type(p_value)
+ _plain_value = p_value
+ _typed_value = _cast_to_type(p_value, _type)
+
+
+ func _scan_type(p_value: String) -> int:
+ if p_value.begins_with("\"") and p_value.ends_with("\""):
+ return TYPE_STRING
+ var type_ := GdObjects.string_to_type(p_value)
+ if type_ != TYPE_NIL:
+ return type_
+ if p_value.is_valid_int():
+ return TYPE_INT
+ if p_value.is_valid_float():
+ return TYPE_FLOAT
+ if p_value.is_valid_hex_number():
+ return TYPE_INT
+ return TYPE_OBJECT
+
+
+ func _cast_to_type(p_value :String, p_type: int) -> Variant:
+ match p_type:
+ TYPE_STRING:
+ return p_value#.substr(1, p_value.length() - 2)
+ TYPE_INT:
+ return p_value.to_int()
+ TYPE_FLOAT:
+ return p_value.to_float()
+ return p_value
+
+
+ func is_variable() -> bool:
+ return true
+
+
+ func type() -> int:
+ return _type
+
+
+ func value() -> Variant:
+ return _typed_value
+
+
+ func plain_value() -> String:
+ return _plain_value
+
+
+ func _to_string() -> String:
+ return "Variable{%s: %s : '%s'}" % [_plain_value, GdObjects.type_as_string(_type), _token]
+
+
+class RegExToken extends Token:
+ var _regex: RegEx
+ var _extract_group_index: int
+ var _value := ""
+
+
+ func _init(token: String, regex: RegEx, extract_group_index: int = -1) -> void:
+ super(token, false)
+ _regex = regex
+ _extract_group_index = extract_group_index
+
+
+ func match(input: String, pos: int) -> bool:
+ var matching := _regex.search(input, pos)
+ if matching == null or pos != matching.get_start():
+ return false
+ if _extract_group_index != -1:
+ _value = matching.get_string(_extract_group_index)
+ _consumed = matching.get_end() - matching.get_start()
+ return true
+
+
+ func value() -> String:
+ return _value
+
+
+# Token to parse Fuzzers
+class FuzzerToken extends RegExToken:
+
+
+ func _init(regex: RegEx) -> void:
+ super("fuzzer", regex, 1)
+
+
+ func name() -> String:
+ return value()
+
+
+ func type() -> int:
+ return GdObjects.TYPE_FUZZER
+
+
+ func _to_string() -> String:
+ return "FuzzerToken{%s: '%s'}" % [value(), _token]
+
+
+class TokenInnerClass extends RegExToken:
+ var _content := PackedStringArray()
+
+
+ static func _strip_leading_spaces(input: String) -> String:
+ var characters := input.to_utf8_buffer()
+ while not characters.is_empty():
+ if characters[0] != 0x20:
+ break
+ characters.remove_at(0)
+ return characters.get_string_from_utf8()
+
+
+ static func _consumed_bytes(row: String) -> int:
+ return row.replace(" ", "").replace(" ", "").length()
+
+
+ func _init(token: String, p_regex: RegEx, extract_group_index: int = -1) -> void:
+ super(token, p_regex, extract_group_index)
+
+
+ func is_class_name(clazz_name: String) -> bool:
+ return value() == clazz_name
+
+
+ func content() -> PackedStringArray:
+ return _content
+
+
+ @warning_ignore_start("return_value_discarded")
+ func parse(source_rows: PackedStringArray, offset: int) -> void:
+ # add class signature
+ _content.clear()
+ _content.append(source_rows[offset])
+ # parse class content
+ for row_index in range(offset+1, source_rows.size()):
+ # scan until next non tab
+ var source_row := source_rows[row_index]
+ var row := TokenInnerClass._strip_leading_spaces(source_row)
+ if row.is_empty() or row.begins_with("\t") or row.begins_with("#"):
+ # fold all line to left by removing leading tabs and spaces
+ if source_row.begins_with("\t"):
+ source_row = source_row.trim_prefix("\t")
+ # refomat invalid empty lines
+ if source_row.dedent().is_empty():
+ _content.append("")
+ else:
+ _content.append(source_row)
+ continue
+ break
+ _consumed += TokenInnerClass._consumed_bytes("".join(_content))
+ @warning_ignore_restore("return_value_discarded")
+
+
+ func _to_string() -> String:
+ return "TokenInnerClass{%s}" % [value()]
+
+
+
+func get_token(input: String, current_index: int) -> Token:
+ for t in TOKENS:
+ if t.match(input, current_index):
+ return t
+ return TOKEN_NOT_MATCH
+
+
+func next_token(input: String, current_index: int, ignore_tokens :Array[Token] = []) -> Token:
+ var token := TOKEN_NOT_MATCH
+ for t :Token in TOKENS.filter(func(t :Token) -> bool: return not ignore_tokens.has(t)):
+
+ if t.match(input, current_index):
+ token = t
+ break
+ if token == OPERATOR_SUB:
+ token = tokenize_value(input, current_index, token)
+ if token == TOKEN_NOT_MATCH:
+ return tokenize_value(input, current_index, token, ignore_tokens.has(TOKEN_FUNCTION))
+ return token
+
+
+func tokenize_value(input: String, current: int, token: Token, ignore_dots := false) -> Token:
+ var next := 0
+ var current_token := ""
+ # test for '--', '+-', '*-', '/-', '%-', or at least '-x'
+ var test_for_sign := (token == null or token.is_operator()) and input[current] == "-"
+ while current + next < len(input):
+ var character := input[current + next] as String
+ # if first charater a sign
+ # or allowend charset
+ # or is a float value
+ if (test_for_sign and next==0) \
+ or is_allowed_character(character) \
+ or (character == "." and (ignore_dots or current_token.is_valid_int())):
+ current_token += character
+ next += 1
+ continue
+ break
+ if current_token != "":
+ return Variable.new(current_token)
+ return TOKEN_NOT_MATCH
+
+
+# const ALLOWED_CHARACTERS := "0123456789_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\""
+func is_allowed_character(input: String) -> bool:
+ var code_point := input.unicode_at(0)
+ # Unicode
+ if code_point > 127:
+ # This is a Unicode character (Chinese, Japanese, etc.)
+ return true
+ # ASCII digit 0-9
+ if code_point >= 48 and code_point <= 57:
+ return true
+ # ASCII lowercase a-z
+ if code_point >= 97 and code_point <= 122:
+ return true
+ # ASCII uppercase A-Z
+ if code_point >= 65 and code_point <= 90:
+ return true
+ # underscore _
+ if code_point == 95:
+ return true
+ # quotes '"
+ if code_point == 34 or code_point == 39:
+ return true
+ return false
+
+
+func parse_return_token(input: String) -> Variable:
+ var index := input.rfind(TOKEN_FUNCTION_RETURN_TYPE._token)
+ if index == -1:
+ return TOKEN_NOT_MATCH
+ index += TOKEN_FUNCTION_RETURN_TYPE._consumed
+ # We scan for the return value exclusive '.' token because it could be referenced to a
+ # external or internal class e.g. 'func foo() -> InnerClass.Bar:'
+ var token := next_token(input, index, [TOKEN_FUNCTION])
+ while !token.is_variable() and token != TOKEN_NOT_MATCH:
+ index += token._consumed
+ token = next_token(input, index, [TOKEN_FUNCTION])
+ return token
+
+
+func get_function_descriptors(script: GDScript, included_functions: PackedStringArray = []) -> Array[GdFunctionDescriptor]:
+ var fds: Array[GdFunctionDescriptor] = []
+ for method_descriptor in script.get_script_method_list():
+ var func_name: String = method_descriptor["name"]
+ if included_functions.is_empty() or func_name in included_functions:
+ # exclude type set/geters
+ if is_getter_or_setter(func_name):
+ continue
+ if not fds.any(func(fd: GdFunctionDescriptor) -> bool: return fd.name() == func_name):
+ fds.append(GdFunctionDescriptor.extract_from(method_descriptor, false))
+
+ # we need to enrich it by default arguments and line number by parsing the script
+ # the engine core functions has no valid methods to get this info
+ _prescan_script(script)
+ _enrich_function_descriptor(script, fds)
+ return fds
+
+
+func is_getter_or_setter(func_name: String) -> bool:
+ return func_name.begins_with("@") and (func_name.ends_with("getter") or func_name.ends_with("setter"))
+
+
+func _parse_function_arguments(input: String) -> Array[Dictionary]:
+ var arguments: Array[Dictionary] = []
+ var current_index := 0
+ var token: Token = null
+ var bracket := 0
+ var in_function := false
+
+
+ while current_index < len(input):
+ token = next_token(input, current_index)
+ # fallback to not end in a endless loop
+ if token == TOKEN_NOT_MATCH:
+ var error : = """
+ Parsing Error: Invalid token at pos %d found.
+ Please report this error!
+ source_code:
+ --------------------------------------------------------------
+ %s
+ --------------------------------------------------------------
+ """.dedent() % [current_index, input]
+ push_error(error)
+ current_index += 1
+ continue
+ current_index += token._consumed
+ if token.is_skippable():
+ continue
+ if token == TOKEN_BRACKET_ROUND_OPEN :
+ in_function = true
+ bracket += 1
+ if token == TOKEN_BRACKET_ROUND_CLOSE:
+ bracket -= 1
+ # if function end?
+ if in_function and bracket == 0:
+ return arguments
+ # is function
+ if token == TOKEN_FUNCTION_DECLARATION:
+ continue
+
+ # is value argument
+ if in_function:
+ var arg_value := ""
+ var current_argument := {
+ "name" : "",
+ "value" : GdFunctionArgument.UNDEFINED,
+ "type" : TYPE_VARIANT
+ }
+
+ # parse type and default value
+ while current_index < len(input):
+ token = next_token(input, current_index)
+ current_index += token._consumed
+ if token.is_skippable():
+ continue
+
+ if token.is_variable() && current_argument["name"] == "":
+ arguments.append(current_argument)
+ current_argument["name"] = (token as Variable).plain_value()
+ continue
+
+ match token:
+ # is fuzzer argument
+ TOKEN_ARGUMENT_FUZZER:
+ arg_value = _parse_end_function(input.substr(current_index), true)
+ current_index += arg_value.length()
+ current_argument["name"] = (token as FuzzerToken).name()
+ current_argument["value"] = arg_value.lstrip(" ")
+ current_argument["type"] = TYPE_FUZZER
+ arguments.append(current_argument)
+ continue
+
+ TOKEN_ARGUMENT_VARIADIC:
+ current_argument["type"] = TYPE_VARARG
+
+ TOKEN_ARGUMENT_TYPE:
+ token = next_token(input, current_index)
+ if token == TOKEN_SPACE:
+ current_index += token._consumed
+ token = next_token(input, current_index)
+ current_index += token._consumed
+ if current_argument["type"] != TYPE_VARARG:
+ current_argument["type"] = GdObjects.string_to_type((token as Variable).plain_value())
+
+ TOKEN_ARGUMENT_TYPE_ASIGNMENT:
+ arg_value = _parse_end_function(input.substr(current_index), true)
+ current_index += arg_value.length()
+ current_argument["value"] = arg_value.lstrip(" ")
+ TOKEN_ARGUMENT_ASIGNMENT:
+ token = next_token(input, current_index)
+ arg_value = _parse_end_function(input.substr(current_index), true)
+ current_index += arg_value.length()
+ current_argument["value"] = arg_value.lstrip(" ")
+
+ TOKEN_BRACKET_SQUARE_OPEN:
+ bracket += 1
+ TOKEN_BRACKET_CURLY_OPEN:
+ bracket += 1
+ TOKEN_BRACKET_ROUND_OPEN :
+ bracket += 1
+ # if value a function?
+ if bracket > 1:
+ # complete the argument value
+ var func_begin := input.substr(current_index-TOKEN_BRACKET_ROUND_OPEN ._consumed)
+ var func_body := _parse_end_function(func_begin)
+ arg_value += func_body
+ # fix parse index to end of value
+ current_index += func_body.length() - TOKEN_BRACKET_ROUND_OPEN ._consumed - TOKEN_BRACKET_ROUND_CLOSE._consumed
+ TOKEN_BRACKET_SQUARE_CLOSE:
+ bracket -= 1
+ TOKEN_BRACKET_CURLY_CLOSE:
+ bracket -= 1
+ TOKEN_BRACKET_ROUND_CLOSE:
+ bracket -= 1
+ # end of function
+ if bracket == 0:
+ break
+ TOKEN_ARGUMENT_SEPARATOR:
+ if bracket <= 1:
+ # next argument
+ current_argument = {
+ "name" : "",
+ "value" : GdFunctionArgument.UNDEFINED,
+ "type" : GdObjects.TYPE_VARIANT
+ }
+ continue
+ return arguments
+
+
+func _parse_end_function(input: String, remove_trailing_char := false) -> String:
+ # find end of function
+ var current_index := 0
+ var bracket_count := 0
+ var in_array := 0
+ var in_dict := 0
+ var end_of_func := false
+
+ while current_index < len(input) and not end_of_func:
+ var character := input[current_index]
+ # step over strings
+ if character == "'" :
+ current_index = input.find("'", current_index+1) + 1
+ if current_index == 0:
+ push_error("Parsing error on '%s', can't evaluate end of string." % input)
+ return ""
+ continue
+ if character == '"' :
+ # test for string blocks
+ if input.find('"""', current_index) == current_index:
+ current_index = input.find('"""', current_index+3) + 3
+ else:
+ current_index = input.find('"', current_index+1) + 1
+ if current_index == 0:
+ push_error("Parsing error on '%s', can't evaluate end of string." % input)
+ return ""
+ continue
+
+ match character:
+ # count if inside an array
+ "[": in_array += 1
+ "]": in_array -= 1
+ # count if inside an dictionary
+ "{": in_dict += 1
+ "}": in_dict -= 1
+ # count if inside a function
+ "(": bracket_count += 1
+ ")":
+ bracket_count -= 1
+ if bracket_count < 0 and in_array <= 0 and in_dict <= 0:
+ end_of_func = true
+ ",":
+ if bracket_count == 0 and in_array == 0 and in_dict <= 0:
+ end_of_func = true
+ current_index += 1
+ if remove_trailing_char:
+ # check if the parsed value ends with comma or end of doubled breaked
+ # `<value>,` or `<function>())`
+ var trailing_char := input[current_index-1]
+ if trailing_char == ',' or (bracket_count < 0 and trailing_char == ')'):
+ return input.substr(0, current_index-1)
+ return input.substr(0, current_index)
+
+
+func extract_inner_class(source_rows: PackedStringArray, clazz_name :String) -> PackedStringArray:
+ for row_index in source_rows.size():
+ var input := source_rows[row_index]
+ var token := next_token(input, 0)
+ if token.is_inner_class():
+ @warning_ignore("unsafe_method_access")
+ if token.is_class_name(clazz_name):
+ @warning_ignore("unsafe_method_access")
+ token.parse(source_rows, row_index)
+ @warning_ignore("unsafe_method_access")
+ return token.content()
+ return PackedStringArray()
+
+
+func extract_func_signature(rows: PackedStringArray, index: int) -> String:
+ var signature := ""
+
+ for rowIndex in range(index, rows.size()):
+ var row := rows[rowIndex]
+ row = _regex_strip_comments.sub(row, "").strip_edges(false)
+ if row.is_empty():
+ continue
+ signature += row + "\n"
+ if is_func_end(row):
+ return signature.strip_edges()
+ push_error("Can't fully extract function signature of '%s'" % rows[index])
+ return ""
+
+
+func get_class_name(script :GDScript) -> String:
+ var source_code := GdScriptParser.to_unix_format(script.source_code)
+ var source_rows := source_code.split("\n")
+
+ for index :int in min(10, source_rows.size()):
+ var input := source_rows[index]
+ var token := next_token(input, 0)
+ if token == TOKEN_CLASS_NAME:
+ return token.value()
+ # if no class_name found extract from file name
+ return GdObjects.to_pascal_case(script.resource_path.get_basename().get_file())
+
+
+func parse_func_name(input: String) -> String:
+ if TOKEN_FUNCTION_DECLARATION.match(input, 0):
+ return TOKEN_FUNCTION_DECLARATION.value()
+ if TOKEN_FUNCTION_STATIC_DECLARATION.match(input, 0):
+ return TOKEN_FUNCTION_STATIC_DECLARATION.value()
+ push_error("Can't extract function name from '%s'" % input)
+ return ""
+
+
+## Enriches the function descriptor by line number and argument default values
+## - enrich all function descriptors form current script up to all inherited scrips
+func _enrich_function_descriptor(script: GDScript, fds: Array[GdFunctionDescriptor]) -> void:
+ var enriched_functions := {} # Use Dictionary for O(1) lookup instead of PackedStringArray
+ var script_to_scan := script
+ while script_to_scan != null:
+ # do not scan the test suite base class itself
+ if script_to_scan.resource_path == "res://addons/gdUnit4/src/GdUnitTestSuite.gd":
+ break
+
+ var rows := script_to_scan.source_code.split("\n")
+ for rowIndex in rows.size():
+ var input := rows[rowIndex]
+ # step over inner class functions
+ if input.begins_with("\t"):
+ continue
+ # skip comments and empty lines
+ if input.begins_with("#") or input.length() == 0:
+ continue
+ var token := next_token(input, 0)
+ if token != TOKEN_FUNCTION_STATIC_DECLARATION and token != TOKEN_FUNCTION_DECLARATION:
+ continue
+
+ var function_name: String = token.value()
+ # Skip if already enriched (from parent class scan)
+ if enriched_functions.has(function_name):
+ continue
+
+ # Find matching function descriptor
+ var fd: GdFunctionDescriptor = null
+ for candidate in fds:
+ if candidate.name() == function_name:
+ fd = candidate
+ break
+ if fd == null:
+ continue
+ # Mark as enriched
+ enriched_functions[function_name] = true
+ var func_signature := extract_func_signature(rows, rowIndex)
+ var func_arguments := _parse_function_arguments(func_signature)
+ # enrich missing default values
+ fd.enrich_arguments(func_arguments)
+ fd.enrich_file_info(script_to_scan.resource_path, rowIndex + 1)
+ fd._is_coroutine = is_func_coroutine(rows, rowIndex)
+ # enrich return class name if not set
+ if fd.return_type() == TYPE_OBJECT and fd._return_class in ["", "Resource", "RefCounted"]:
+ var var_token := parse_return_token(func_signature)
+ if var_token != TOKEN_NOT_MATCH and var_token.type() == TYPE_OBJECT:
+ fd._return_class = _patch_inner_class_names(var_token.plain_value(), "")
+ # if the script ihnerits we need to scan this also
+ script_to_scan = script_to_scan.get_base_script()
+
+
+func is_func_coroutine(rows :PackedStringArray, index :int) -> bool:
+ var is_coroutine := false
+ for rowIndex in range(index+1, rows.size()):
+ var input := rows[rowIndex].strip_edges()
+ if input.begins_with("#") or input.is_empty():
+ continue
+ var token := next_token(input, 0)
+ # scan until next function
+ if token == TOKEN_FUNCTION_STATIC_DECLARATION or token == TOKEN_FUNCTION_DECLARATION:
+ break
+
+ if _is_awaiting.search(input):
+ return true
+ return is_coroutine
+
+
+func is_inner_class(clazz_path :PackedStringArray) -> bool:
+ return clazz_path.size() > 1
+
+
+func is_func_end(row :String) -> bool:
+ return row.strip_edges(false, true).ends_with(":")
+
+
+func _patch_inner_class_names(clazz :String, clazz_name :String = "") -> String:
+ var inner_clazz_name := clazz.split(".")[0]
+ if _scanned_inner_classes.has(inner_clazz_name):
+ return inner_clazz_name
+ #var base_clazz := clazz_name.split(".")[0]
+ #return base_clazz + "." + clazz
+ if _script_constants.has(clazz):
+ return clazz_name + "." + clazz
+ return clazz
+
+
+func _prescan_script(script: GDScript) -> void:
+ _script_constants = script.get_script_constant_map()
+ for key :String in _script_constants.keys():
+ var value :Variant = _script_constants.get(key)
+ if value is GDScript:
+ @warning_ignore("return_value_discarded")
+ _scanned_inner_classes.append(key)
+
+
+func parse(clazz_name :String, clazz_path :PackedStringArray) -> GdUnitResult:
+ if clazz_path.is_empty():
+ return GdUnitResult.error("Invalid script path '%s'" % clazz_path)
+ var is_inner_class_ := is_inner_class(clazz_path)
+ var script :GDScript = load(clazz_path[0])
+ _prescan_script(script)
+
+ if is_inner_class_:
+ var inner_class_name := clazz_path[1]
+ if _scanned_inner_classes.has(inner_class_name):
+ # do load only on inner class source code and enrich the stored script instance
+ var source_code := _load_inner_class(script, inner_class_name)
+ script = _script_constants.get(inner_class_name)
+ script.source_code = source_code
+ var function_descriptors := get_function_descriptors(script)
+ var gd_class := GdClassDescriptor.new(clazz_name, is_inner_class_, function_descriptors)
+ return GdUnitResult.success(gd_class)
+
+
+func _load_inner_class(script: GDScript, inner_clazz: String) -> String:
+ var source_rows := GdScriptParser.to_unix_format(script.source_code).split("\n")
+ # extract all inner class names
+ var inner_class_code := extract_inner_class(source_rows, inner_clazz)
+ return "\n".join(inner_class_code)
diff --git a/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid b/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid
new file mode 100644
index 0000000..11d95e1
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdScriptParser.gd.uid
@@ -0,0 +1 @@
+uid://djbak301ao2c0
diff --git a/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd
new file mode 100644
index 0000000..9faf830
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd
@@ -0,0 +1,74 @@
+class_name GdUnitExpressionRunner
+extends RefCounted
+
+const CLASS_TEMPLATE = """
+class_name _ExpressionRunner extends '${clazz_path}'
+
+func __run_expression() -> Variant:
+ return $expression
+
+"""
+
+var constructor_args_regex := RegEx.create_from_string("new\\((?<args>.*)\\)")
+
+
+func execute(src_script: GDScript, value: Variant) -> Variant:
+ if typeof(value) != TYPE_STRING:
+ return value
+
+ var expression: String = value
+ var parameter_map := src_script.get_script_constant_map()
+ for key: String in parameter_map.keys():
+ var parameter_value: Variant = parameter_map[key]
+ # check we need to construct from inner class
+ # we need to use the original class instance from the script_constant_map otherwise we run into a runtime error
+ if expression.begins_with(key + ".new") and parameter_value is GDScript:
+ var object: GDScript = parameter_value
+ var args := build_constructor_arguments(parameter_map, expression.substr(expression.find("new")))
+ if args.is_empty():
+ return object.new()
+ return object.callv("new", args)
+
+ var script := GDScript.new()
+ var resource_path := "res://addons/gdUnit4/src/Fuzzers.gd" if src_script.resource_path.is_empty() else src_script.resource_path
+ script.source_code = CLASS_TEMPLATE.dedent()\
+ .replace("${clazz_path}", resource_path)\
+ .replace("$expression", expression)
+ #script.take_over_path(resource_path)
+ @warning_ignore("return_value_discarded")
+ script.reload(true)
+ var runner: Object = script.new()
+ if runner.has_method("queue_free"):
+ (runner as Node).queue_free()
+ @warning_ignore("unsafe_method_access")
+ return runner.__run_expression()
+
+
+func build_constructor_arguments(parameter_map: Dictionary, expression: String) -> Array[Variant]:
+ var result := constructor_args_regex.search(expression)
+ var extracted_arguments := result.get_string("args").strip_edges()
+ if extracted_arguments.is_empty():
+ return []
+ var arguments :Array = extracted_arguments.split(",")
+ return arguments.map(func(argument: String) -> Variant:
+ var value := argument.strip_edges()
+
+ # is argument an constant value
+ if parameter_map.has(value):
+ return parameter_map[value]
+ # is typed named value like Vector3.ONE
+ for type:int in GdObjects.TYPE_AS_STRING_MAPPINGS:
+ var type_as_string:String = GdObjects.TYPE_AS_STRING_MAPPINGS[type]
+ if value.begins_with(type_as_string):
+ return type_convert(value, type)
+ # is value a string
+ if value.begins_with("'") or value.begins_with('"'):
+ return value.trim_prefix("'").trim_suffix("'").trim_prefix('"').trim_suffix('"')
+ # fallback to default value converting
+ return str_to_var(value)
+ )
+
+
+func to_fuzzer(src_script: GDScript, expression: String) -> Fuzzer:
+ @warning_ignore("unsafe_cast")
+ return execute(src_script, expression) as Fuzzer
diff --git a/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid
new file mode 100644
index 0000000..54da916
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdUnitExpressionRunner.gd.uid
@@ -0,0 +1 @@
+uid://cstpl6o675qkp
diff --git a/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd
new file mode 100644
index 0000000..527661d
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd
@@ -0,0 +1,163 @@
+## @deprecated see GdFunctionParameterSetResolver
+class_name GdUnitTestParameterSetResolver
+extends RefCounted
+
+const CLASS_TEMPLATE = """
+class_name _ParameterExtractor extends '${clazz_path}'
+
+func __extract_test_parameters() -> Array:
+ return ${test_params}
+
+"""
+
+const EXCLUDE_PROPERTIES_TO_COPY = [
+ "script",
+ "type",
+ "Node",
+ "_import_path"]
+
+
+var _fd: GdFunctionDescriptor
+var _static_sets_by_index := {}
+var _is_static := true
+
+func _init(fd: GdFunctionDescriptor) -> void:
+ _fd = fd
+
+
+func is_parameterized() -> bool:
+ return _fd.is_parameterized()
+
+
+func is_parameter_sets_static() -> bool:
+ return _is_static
+
+
+func is_parameter_set_static(index: int) -> bool:
+ return _is_static and _static_sets_by_index.get(index, false)
+
+
+# validates the given arguments are complete and matches to required input fields of the test function
+func validate(parameter_sets: Array, parameter_set_index: int) -> GdUnitResult:
+ if parameter_sets.size() < parameter_set_index:
+ return GdUnitResult.error("Internal error: the resolved paremeterset has invalid size.")
+
+ var input_values: Array = parameter_sets[parameter_set_index]
+ if input_values == null:
+ return GdUnitResult.error("The parameter set '%s' must be an Array!" % parameter_sets[parameter_set_index])
+
+ # check given parameter set with test case arguments
+ var input_arguments := _fd.args()
+ var expected_arg_count := input_arguments.size() - 1 #(-1 we exclude the parameter set itself)
+ var current_arg_count := input_values.size()
+ if current_arg_count != expected_arg_count:
+ var arg_names := input_arguments\
+ .filter(func(arg: GdFunctionArgument) -> bool: return not arg.is_parameter_set())\
+ .map(func(arg: GdFunctionArgument) -> String: return str(arg))
+
+ return GdUnitResult.error("""
+ The test data set at index (%d) does not match the expected test arguments:
+ test function: [color=snow]func test...(%s)[/color]
+ test input values: [color=snow]%s[/color]
+ """
+ .dedent() % [parameter_set_index, ",".join(arg_names), input_values])
+ return GdUnitTestParameterSetResolver.validate_parameter_types(input_arguments, input_values)
+
+
+static func validate_parameter_types(input_arguments: Array[GdFunctionArgument], input_values: Array) -> GdUnitResult:
+ for i in input_arguments.size():
+ var input_param: GdFunctionArgument = input_arguments[i]
+ # only check the test input arguments
+ if input_param.is_parameter_set():
+ continue
+ var input_param_type := input_param.type()
+ var input_value :Variant = input_values[i]
+ var input_value_type := typeof(input_value)
+ # input parameter is not typed or is Variant we skip the type test
+ if input_param_type == TYPE_NIL or input_param_type == GdObjects.TYPE_VARIANT:
+ continue
+ # is input type enum allow int values
+ if input_param_type == GdObjects.TYPE_VARIANT and input_value_type == TYPE_INT:
+ continue
+ # allow only equal types and object == null
+ if input_param_type == TYPE_OBJECT and input_value_type == TYPE_NIL:
+ continue
+ if input_param_type != input_value_type:
+ return GdUnitResult.error("""
+ The test data value does not match the expected input type!
+ input value: [color=snow]'%s', <%s>[/color]
+ expected argument: [color=snow]%s[/color]
+ """
+ .dedent() % [input_value, type_string(input_value_type), str(input_param)])
+ return GdUnitResult.success("No errors found.")
+
+
+func _extract_property_names(node :Node) -> PackedStringArray:
+ return node.get_property_list()\
+ .map(func(property :Dictionary) -> String: return property["name"])\
+ .filter(func(property :String) -> bool: return !EXCLUDE_PROPERTIES_TO_COPY.has(property))
+
+
+# tests if the test property set contains an property reference by name, if not the parameter set holds only static values
+func _is_static_parameter_set(parameters :String, property_names :PackedStringArray) -> bool:
+ for property_name in property_names:
+ if parameters.contains(property_name):
+ _is_static = false
+ return false
+ return true
+
+
+# extracts the arguments from the given test case, using kind of reflection solution
+# to restore the parameters from a string representation to real instance type
+func load_parameter_sets(test_suite: Node) -> GdUnitResult:
+ var source_script: Script = test_suite.get_script()
+ var parameter_arg := GdFunctionArgument.get_parameter_set(_fd.args())
+ var source_code := CLASS_TEMPLATE \
+ .replace("${clazz_path}", source_script.resource_path) \
+ .replace("${test_params}", parameter_arg.value_as_string())
+ var script := GDScript.new()
+ script.source_code = source_code
+ # enable this lines only for debuging
+ #script.resource_path = GdUnitFileAccess.create_temp_dir("parameter_extract") + "/%s__.gd" % test_case.get_name()
+ #DirAccess.remove_absolute(script.resource_path)
+ #ResourceSaver.save(script, script.resource_path)
+ var result := script.reload()
+ if result != OK:
+ return GdUnitResult.error("Extracting test parameters failed! Script loading error: %s" % error_string(result))
+ var instance :Object = script.new()
+ GdUnitTestParameterSetResolver.copy_properties(test_suite, instance)
+ (instance as Node).queue_free()
+ var parameter_sets: Array = instance.call("__extract_test_parameters")
+ fixure_typed_parameters(parameter_sets, _fd.args())
+ return GdUnitResult.success(parameter_sets)
+
+
+func fixure_typed_parameters(parameter_sets: Array, arg_descriptors: Array[GdFunctionArgument]) -> Array:
+ for parameter_set_index in parameter_sets.size():
+ var parameter_set: Array = parameter_sets[parameter_set_index]
+ # run over all function arguments
+ for parameter_index in parameter_set.size():
+ var parameter :Variant = parameter_set[parameter_index]
+ var arg_descriptor: GdFunctionArgument = arg_descriptors[parameter_index]
+ if parameter is Array:
+ var as_array: Array = parameter
+ # we need to convert the untyped array to the expected typed version
+ if arg_descriptor.is_typed_array():
+ parameter_set[parameter_index] = Array(as_array, arg_descriptor.type_hint(), "", null)
+ return parameter_sets
+
+
+static func copy_properties(source: Object, dest: Object) -> void:
+ for property in source.get_property_list():
+ var property_name :String = property["name"]
+ var property_value :Variant = source.get(property_name)
+ if EXCLUDE_PROPERTIES_TO_COPY.has(property_name):
+ continue
+ #if dest.get(property_name) == null:
+ # prints("|%s|" % property_name, source.get(property_name))
+
+ # check for invalid name property
+ if property_name == "name" and property_value == "":
+ dest.set(property_name, "<empty>");
+ continue
+ dest.set(property_name, property_value)
diff --git a/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid
new file mode 100644
index 0000000..a12fb18
--- /dev/null
+++ b/addons/gdUnit4/src/core/parse/GdUnitTestParameterSetResolver.gd.uid
@@ -0,0 +1 @@
+uid://deasps2102or7
diff --git a/addons/gdUnit4/src/core/report/GdUnitReport.gd b/addons/gdUnit4/src/core/report/GdUnitReport.gd
new file mode 100644
index 0000000..aaf309f
--- /dev/null
+++ b/addons/gdUnit4/src/core/report/GdUnitReport.gd
@@ -0,0 +1,84 @@
+class_name GdUnitReport
+extends Resource
+
+# report type
+enum {
+ SUCCESS,
+ WARN,
+ FAILURE,
+ ORPHAN,
+ TERMINATED,
+ INTERUPTED,
+ ABORT,
+ SKIPPED,
+}
+
+var _type :int
+var _line_number :int
+var _message :String
+var _current_value: Variant
+
+
+func create(p_type :int, p_line_number :int, p_message :String) -> GdUnitReport:
+ _type = p_type
+ _line_number = p_line_number
+ _message = p_message
+ return self
+
+
+func with_current_value(value: Variant) -> GdUnitReport:
+ _current_value = value
+ return self
+
+
+func type() -> int:
+ return _type
+
+
+func line_number() -> int:
+ return _line_number
+
+
+func message() -> String:
+ return _message
+
+
+func is_skipped() -> bool:
+ return _type == SKIPPED
+
+
+func is_warning() -> bool:
+ return _type == WARN
+
+
+func is_failure() -> bool:
+ return _type == FAILURE
+
+
+func is_error() -> bool:
+ return _type == TERMINATED or _type == INTERUPTED or _type == ABORT
+
+
+func is_orphan() -> bool:
+ return _type == ORPHAN
+
+
+func _to_string() -> String:
+ if _line_number == -1:
+ return "[color=green]line [/color][color=aqua]<n/a>:[/color] %s" % [_message]
+ return "[color=green]line [/color][color=aqua]%d:[/color] %s" % [_line_number, _message]
+
+
+func serialize() -> Dictionary:
+ return {
+ "type" :_type,
+ "line_number" :_line_number,
+ "message" :_message
+ }
+
+
+func deserialize(serialized :Dictionary) -> GdUnitReport:
+ _type = serialized["type"]
+ _line_number = serialized["line_number"]
+ _message = serialized["message"]
+ return self
diff --git a/addons/gdUnit4/src/core/report/GdUnitReport.gd.uid b/addons/gdUnit4/src/core/report/GdUnitReport.gd.uid
new file mode 100644
index 0000000..934a280
--- /dev/null
+++ b/addons/gdUnit4/src/core/report/GdUnitReport.gd.uid
@@ -0,0 +1 @@
+uid://c27ddgo83osir
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd
new file mode 100644
index 0000000..34dcfa3
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd
@@ -0,0 +1,470 @@
+#warning-ignore-all:return_value_discarded
+class_name GdUnitTestCIRunner
+extends "res://addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd"
+## Command line test runner implementation.[br]
+## [br]
+## This runner is designed for CI/CD pipelines and command line test execution.[br]
+## Features:[br]
+## - Command line options for test configuration[br]
+## - HTML and JUnit report generation[br]
+## - Console output with colored formatting[br]
+## - Progress and error reporting[br]
+## - Test history management[br]
+## [br]
+## Example usage:[br]
+## [codeblock]
+## # Run all tests in a directory
+## runtest -a <directory>
+##
+## # Run specific test suite with ignored tests
+## runtest -a <directory> -i <testsuite:test_name>
+## [/codeblock]
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+var _console := GdUnitCSIMessageWriter.new()
+var _console_reporter: GdUnitConsoleTestReporter
+var _headless_mode_ignore := false
+var _runner_config_file := ""
+var _debug_cmd_args := PackedStringArray()
+var _included_tests := PackedStringArray()
+var _excluded_tests := PackedStringArray()
+
+## Command line options configuration
+var _cmd_options := CmdOptions.new([
+ CmdOption.new(
+ "-a, --add",
+ "-a <directory|path of testsuite>",
+ "Adds the given test suite or directory to the execution pipeline.",
+ TYPE_STRING
+ ),
+ CmdOption.new(
+ "-i, --ignore",
+ "-i <testsuite_name|testsuite_name:test-name>",
+ "Adds the given test suite or test case to the ignore list.",
+ TYPE_STRING
+ ),
+ CmdOption.new(
+ "-c, --continue",
+ "",
+ """By default GdUnit will abort checked first test failure to be fail fast,
+ instead of stop after first failure you can use this option to run the complete test set.""".dedent()
+ ),
+ CmdOption.new(
+ "-conf, --config",
+ "-conf [testconfiguration.cfg]",
+ "Run all tests by given test configuration. Default is 'GdUnitRunner.cfg'",
+ TYPE_STRING,
+ true
+ ),
+ CmdOption.new(
+ "-help", "",
+ "Shows this help message."
+ ),
+ CmdOption.new("--help-advanced", "",
+ "Shows advanced options."
+ )
+ ],
+ [
+ # advanced options
+ CmdOption.new(
+ "-rd, --report-directory",
+ "-rd <directory>",
+ "Specifies the output directory in which the reports are to be written. The default is res://reports/.",
+ TYPE_STRING,
+ true
+ ),
+ CmdOption.new(
+ "-rc, --report-count",
+ "-rc <count>",
+ "Specifies how many reports are saved before they are deleted. The default is %s." % str(GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT),
+ TYPE_INT,
+ true
+ ),
+ #CmdOption.new("--list-suites", "--list-suites [directory]", "Lists all test suites located in the given directory.", TYPE_STRING),
+ #CmdOption.new("--describe-suite", "--describe-suite <suite name>", "Shows the description of selected test suite.", TYPE_STRING),
+ CmdOption.new(
+ "--info", "",
+ "Shows the GdUnit version info"
+ ),
+ CmdOption.new(
+ "--selftest", "",
+ "Runs the GdUnit self test"
+ ),
+ CmdOption.new(
+ "--ignoreHeadlessMode",
+ "--ignoreHeadlessMode",
+ "By default, running GdUnit4 in headless mode is not allowed. You can switch off the headless mode check by set this property."
+ ),
+ ])
+
+
+func _init() -> void:
+ super()
+
+
+func _ready() -> void:
+ super()
+ # stop checked first test failure to fail fast
+ _executor.fail_fast(true)
+ _console_reporter = GdUnitConsoleTestReporter.new(_console, true)
+ GdUnitSignals.instance().gdunit_message.connect(_on_send_message)
+
+
+func _notification(what: int) -> void:
+ super(what)
+ if what == NOTIFICATION_PREDELETE:
+ prints("Finallize .. done")
+
+
+func init_runner() -> void:
+ init_gd_unit()
+
+
+## Returns the exit code based on test results.[br]
+## Maps test report status to process exit codes.
+func get_exit_code() -> int:
+ return report_exit_code()
+
+
+## Cleanup and quit the runner.[br]
+## [br]
+## [param code] The exit code to return.
+func quit(code: int) -> void:
+ _state = EXIT
+ GdUnitTools.dispose_all()
+ await GdUnitMemoryObserver.gc_on_guarded_instances()
+ await super(code)
+
+
+## Prints info message to console.[br]
+## [br]
+## [param message] The message to print.[br]
+## [param color] Optional color for the message.
+func console_info(message: String, color: Color = Color.WHITE) -> void:
+ _console.color(color).println_message(message)
+
+
+## Prints error message to console.[br]
+## [br]
+## [param message] The error message to print.
+func console_error(message: String) -> void:
+ _console.prints_error(message)
+
+
+## Prints warning message to console.[br]
+## [br]
+## [param message] The warning message to print.
+func console_warning(message: String) -> void:
+ _console.prints_warning(message)
+
+
+## Sets the directory for test reports.[br]
+## [br]
+## [param path] The path where reports should be written.
+func set_report_dir(path: String) -> void:
+ report_base_path = ProjectSettings.globalize_path(GdUnitFileAccess.make_qualified_path(path))
+ console_info(
+ "Set write reports to %s" % report_base_path,
+ Color.DEEP_SKY_BLUE
+ )
+
+
+## Sets how many report files to keep.[br]
+## [br]
+## [param count] The number of reports to keep.
+func set_report_count(count: String) -> void:
+ var report_count := count.to_int()
+ if report_count < 1:
+ console_error(
+ "Invalid report history count '%s' set back to default %d"
+ % [count, GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT]
+ )
+ max_report_history = GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT
+ else:
+ console_info(
+ "Set report history count to %s" % count,
+ Color.DEEP_SKY_BLUE
+ )
+ max_report_history = report_count
+
+
+## Disables fail-fast mode to run all tests.[br]
+## By default tests stop on first failure.
+func disable_fail_fast() -> void:
+ console_info(
+ "Disabled fail fast!",
+ Color.DEEP_SKY_BLUE
+ )
+ @warning_ignore("unsafe_method_access")
+ _executor.fail_fast(false)
+
+
+func run_self_test() -> void:
+ console_info(
+ "Run GdUnit4 self tests.",
+ Color.DEEP_SKY_BLUE
+ )
+ disable_fail_fast()
+
+
+
+## Shows GdUnit and Godot version information.
+func show_version() -> void:
+ console_info(
+ "Godot %s" % Engine.get_version_info().get("string") as String,
+ Color.DARK_SALMON
+ )
+ var config := ConfigFile.new()
+ config.load("addons/gdUnit4/plugin.cfg")
+ console_info(
+ "GdUnit4 %s" % config.get_value("plugin", "version") as String,
+ Color.DARK_SALMON
+ )
+ quit(RETURN_SUCCESS)
+
+
+## Ignores headless mode restrictions.[br]
+## Allows tests to run in headless mode despite limitations.
+func check_headless_mode() -> void:
+ _headless_mode_ignore = true
+
+
+## Shows available command line options.[br]
+## [br]
+## [param show_advanced] Whether to show advanced options.
+func show_options(show_advanced: bool = false) -> void:
+ console_info(
+ """
+ Usage:
+ runtest -a <directory|path of testsuite>
+ runtest -a <directory> -i <path of testsuite|testsuite_name|testsuite_name:test_name>
+ """.dedent(),
+ Color.DARK_SALMON
+ )
+ console_info(
+ "-- Options ---------------------------------------------------------------------------------------",
+ Color.DARK_SALMON
+ )
+ for option in _cmd_options.default_options():
+ descripe_option(option)
+ if show_advanced:
+ console_info(
+ "-- Advanced options --------------------------------------------------------------------------",
+ Color.DARK_SALMON
+ )
+ for option in _cmd_options.advanced_options():
+ descripe_option(option)
+
+
+## Describes a single command line option.[br]
+## [br]
+## [param cmd_option] The option to describe.
+func descripe_option(cmd_option: CmdOption) -> void:
+ console_info(
+ " %-40s" % str(cmd_option.commands()),
+ Color.CORNFLOWER_BLUE
+ )
+ console_info(
+ cmd_option.description(),
+ Color.LIGHT_GREEN
+ )
+ if not cmd_option.help().is_empty():
+ console_info(
+ "%-4s %s" % ["", cmd_option.help()],
+ Color.DARK_TURQUOISE
+ )
+ console_info("")
+
+
+## Loads test configuration from file.[br]
+## [br]
+## [param path] Path to the configuration file.
+func load_test_config(path := GdUnitRunnerConfig.CONFIG_FILE) -> void:
+ console_info(
+ "Loading test configuration %s\n" % path,
+ Color.CORNFLOWER_BLUE
+ )
+ _runner_config_file = path
+ _runner_config.load_config(path)
+
+
+## Shows basic help and exits.
+func show_help() -> void:
+ show_options()
+ quit(RETURN_SUCCESS)
+
+
+## Shows advanced help and exits.
+func show_advanced_help() -> void:
+ show_options(true)
+ quit(RETURN_SUCCESS)
+
+
+## Gets command line arguments.[br]
+## Returns debug args if set, otherwise actual command line args.
+func get_cmdline_args() -> PackedStringArray:
+ if _debug_cmd_args.is_empty():
+ return OS.get_cmdline_args()
+ return _debug_cmd_args
+
+
+## Initializes the test runner and processes command line arguments.
+func init_gd_unit() -> void:
+ console_info(
+ """
+ --------------------------------------------------------------------------------------------------
+ GdUnit4 Comandline Tool
+ --------------------------------------------------------------------------------------------------""".dedent(),
+ Color.DARK_SALMON
+ )
+
+ var cmd_parser := CmdArgumentParser.new(_cmd_options, "GdUnitCmdTool.gd")
+ var result := cmd_parser.parse(get_cmdline_args())
+ if result.is_error():
+ console_error(result.error_message())
+ show_options()
+ console_error("Abnormal exit with %d" % RETURN_ERROR)
+ quit(RETURN_ERROR)
+ return
+ if result.is_empty():
+ show_help()
+ return
+ # build runner config by given commands
+ var commands :Array[CmdCommand] = []
+ @warning_ignore("unsafe_cast")
+ commands.append_array(result.value() as Array)
+ result = (
+ CmdCommandHandler.new(_cmd_options)
+ .register_cb("-help", show_help)
+ .register_cb("--help-advanced", show_advanced_help)
+ .register_cb("-a", add_test_suite)
+ .register_cbv("-a", add_test_suites)
+ .register_cb("-i", skip_test_suite)
+ .register_cbv("-i", skip_test_suites)
+ .register_cb("-rd", set_report_dir)
+ .register_cb("-rc", set_report_count)
+ .register_cb("--selftest", run_self_test)
+ .register_cb("-c", disable_fail_fast)
+ .register_cb("-conf", load_test_config)
+ .register_cb("--info", show_version)
+ .register_cb("--ignoreHeadlessMode", check_headless_mode)
+ .execute(commands)
+ )
+ if result.is_error():
+ console_error(result.error_message())
+ quit(RETURN_ERROR)
+ return
+
+ if DisplayServer.get_name() == "headless":
+ if _headless_mode_ignore:
+ console_warning("""
+ Headless mode is ignored by option '--ignoreHeadlessMode'"
+
+ Please note that tests that use UI interaction do not work correctly in headless mode.
+ Godot 'InputEvents' are not transported by the Godot engine in headless mode and therefore
+ have no effect in the test!
+ """.dedent()
+ )
+ else:
+ console_error("""
+ Headless mode is not supported!
+
+ Please note that tests that use UI interaction do not work correctly in headless mode.
+ Godot 'InputEvents' are not transported by the Godot engine in headless mode and therefore
+ have no effect in the test!
+
+ You can run with '--ignoreHeadlessMode' to swtich off this check.
+ """.dedent()
+ )
+ console_error(
+ "Abnormal exit with %d" % RETURN_ERROR_HEADLESS_NOT_SUPPORTED
+ )
+ quit(RETURN_ERROR_HEADLESS_NOT_SUPPORTED)
+ return
+
+ _test_cases = discover_tests()
+ if _test_cases.is_empty():
+ console_info("No test cases found, abort test run!", Color.YELLOW)
+ console_info("Exit code: %d" % RETURN_SUCCESS, Color.DARK_SALMON)
+ quit(RETURN_SUCCESS)
+ return
+ _state = RUN
+
+
+func discover_tests() -> Array[GdUnitTestCase]:
+ var gdunit_test_discover_added := GdUnitSignals.instance().gdunit_test_discover_added
+
+ _test_cases = _runner_config.test_cases()
+ var scanner := GdUnitTestSuiteScanner.new()
+ for path in _included_tests:
+ var scripts := scanner.scan(path)
+ for script in scripts:
+ GdUnitTestDiscoverer.discover_tests(script, func(test: GdUnitTestCase) -> void:
+ if not is_skipped(test):
+ #_console.println_message("discoverd %s" % test.display_name)
+ _test_cases.append(test)
+ gdunit_test_discover_added.emit(test)
+ )
+
+ return _test_cases
+
+
+func add_test_suite(path: String) -> void:
+ _included_tests.append(path)
+
+
+func add_test_suites(paths: PackedStringArray) -> void:
+ _included_tests.append_array(paths)
+
+
+func skip_test_suite(path: String) -> void:
+ _excluded_tests.append(path)
+
+
+func skip_test_suites(paths: PackedStringArray) -> void:
+ _excluded_tests.append_array(paths)
+
+
+func is_skipped(test: GdUnitTestCase) -> bool:
+ for skipped_info in _excluded_tests:
+
+ # is suite skipped by full path or suite name
+ if skipped_info == test.suite_name or test.source_file.contains(skipped_info):
+ return true
+ var skip_file := skipped_info.replace("res://", "")
+
+ # check for skipped single test
+ if not skip_file.contains(":"):
+ continue
+ var parts: PackedStringArray = skip_file.rsplit(":")
+ var skipped_suite := parts[0]
+ var skipped_test := parts[1]
+ # is suite skipped by full path or suite name
+ if (skipped_suite == test.suite_name or test.source_file.contains(skipped_suite)) and skipped_test == test.test_name:
+ return true
+
+ return false
+
+
+func _on_send_message(message: String) -> void:
+ _console.color(Color.CORNFLOWER_BLUE).println_message(message)
+
+
+func _on_gdunit_event(event: GdUnitEvent) -> void:
+ match event.type():
+ GdUnitEvent.SESSION_START:
+ _console_reporter.test_session = _test_session
+ GdUnitEvent.SESSION_CLOSE:
+ _console_reporter.test_session = null
+
+
+func report_exit_code() -> int:
+ if _console_reporter.total_error_count() + _console_reporter.total_failure_count() > 0:
+ console_info("Exit code: %d" % RETURN_ERROR, Color.FIREBRICK)
+ return RETURN_ERROR
+ if _console_reporter.total_orphan_count() > 0:
+ console_info("Exit code: %d" % RETURN_WARNING, Color.GOLDENROD)
+ return RETURN_WARNING
+ console_info("Exit code: %d" % RETURN_SUCCESS, Color.DARK_SALMON)
+ return RETURN_SUCCESS
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid
new file mode 100644
index 0000000..58e1061
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestCIRunner.gd.uid
@@ -0,0 +1 @@
+uid://dllu6e8jp1nuk
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd
new file mode 100644
index 0000000..0fd8fc7
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd
@@ -0,0 +1,90 @@
+@tool
+extends "res://addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd"
+## Runner implementation used by the editor UI.[br]
+## [br]
+## This runner connects to a GdUnit server via TCP to report test results.[br]
+## Test results are reported in real-time and displayed in the editor UI.[br]
+## [br]
+## The runner uses an RPC message protocol to communicate status and events:[br]
+## - Messages to report progress[br]
+## - Events to report test results[br]
+
+## The TCP client used to connect to the GdUnit server
+@onready var _client: GdUnitTcpClient = $GdUnitTcpClient
+@onready var _version_label: Control = %Version
+
+
+func _init() -> void:
+ super()
+ # We set the default max report history to 1
+ max_report_history = 1
+
+
+func _ready() -> void:
+ super()
+ GdUnit4Version.init_version_label(_version_label)
+
+ var config_result := _runner_config.load_config()
+ if config_result.is_error():
+ push_error(config_result.error_message())
+ _state = EXIT
+ return
+ @warning_ignore("return_value_discarded")
+ _client.connection_failed.connect(_on_connection_failed)
+ GdUnitSignals.instance().gdunit_message.connect(_on_send_message)
+ _executor.fail_fast(_runner_config.is_fail_fast())
+ var result := _client.start("127.0.0.1", _runner_config.server_port())
+ if result.is_error():
+ push_error(result.error_message())
+ return
+
+
+## Cleanup and quit the runner.[br]
+## [br]
+## [param code] The exit code to return.
+func quit(code: int) -> void:
+ if code != RETURN_SUCCESS:
+ _state = EXIT
+ await GdUnitMemoryObserver.gc_on_guarded_instances()
+ await super.quit(code)
+
+
+## Called when the TCP connection to the GdUnit server fails.[br]
+## Stops the test execution.[br]
+## [br]
+## [param message] The error message describing the failure.
+func _on_connection_failed(message: String) -> void:
+ prints("_on_connection_failed", message)
+ _state = STOP
+
+
+## Initializes the test runner.[br]
+## Waits for TCP client connection and then scans for test suites.[br]
+## Reports the number of found test suites via TCP message.
+func init_runner() -> void:
+ # wait until client is connected to the GdUnitServer
+ if _client.is_client_connected():
+ await gdUnitInit()
+ _state = RUN
+
+
+## Initializes the GdUnit framework.[br]
+## Sends initial message about number of test suites.
+func gdUnitInit() -> void:
+ #enable_manuall_polling()
+ _test_cases = _runner_config.test_cases()
+ await get_tree().process_frame
+
+
+## Sends a message via TCP to the GdUnit server.[br]
+## [br]
+## [param message] The message to send.
+func _on_send_message(message: String) -> void:
+ _client.send(RPCMessage.of(message))
+
+
+## Handles GdUnit events by sending them via TCP to the server.[br]
+## [br]
+## [param event] The event to send.
+func _on_gdunit_event(event: GdUnitEvent) -> void:
+ _client.send(RPCGdUnitEvent.of(event))
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid
new file mode 100644
index 0000000..fd35f6b
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd.uid
@@ -0,0 +1 @@
+uid://cc77n13jrf5x6
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn
new file mode 100644
index 0000000..eaa6f1a
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestRunner.tscn
@@ -0,0 +1,34 @@
+[gd_scene load_steps=3 format=3 uid="uid://belidlfknh74r"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/core/runners/GdUnitTestRunner.gd" id="1"]
+[ext_resource type="Script" path="res://addons/gdUnit4/src/network/GdUnitTcpClient.gd" id="2"]
+
+[node name="Control" type="Node"]
+script = ExtResource("1")
+
+[node name="GdUnitTcpClient" type="Node" parent="."]
+script = ExtResource("2")
+
+[node name="HBoxContainer" type="HBoxContainer" parent="."]
+custom_minimum_size = Vector2(0, 24)
+layout_direction = 2
+anchors_preset = 12
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 0
+size_flags_horizontal = 3
+size_flags_vertical = 10
+alignment = 2
+
+[node name="Version" type="RichTextLabel" parent="HBoxContainer"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(128, 0)
+layout_mode = 2
+size_flags_horizontal = 10
+bbcode_enabled = true
+scroll_active = false
+shortcut_keys_enabled = false
+horizontal_alignment = 1
+justification_flags = 0
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd
new file mode 100644
index 0000000..c789847
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd
@@ -0,0 +1,169 @@
+##
+## @since GdUnit4 5.1.0
+##
+## Represents a test execution session in GdUnit4.[br]
+## [br]
+## [i]A test session encapsulates a complete test execution cycle, managing the collection
+## of test cases to be executed and providing communication channels for test events
+## and messages. This class serves as the central coordination point for test execution
+## and allows hooks and other components to interact with the running test session.[/i][br]
+## [br]
+## [b][u]Key Features[/u][/b][br]
+## - [i][b]Test Case Management[/b][/i]: Maintains a collection of test cases to be executed[br]
+## - [i][b]Event Broadcasting[/b][/i]: Forwards GdUnit events to session-specific listeners[br]
+## - [i][b]Message Communication[/b][/i]: Provides a channel for sending messages during test execution[br]
+## - [i][b]Hook Integration[/b][/i]: Passed to test session hooks for startup and shutdown operations[br]
+## [br]
+## [b][u]Usage in Test Hooks[/u][/b]
+## [codeblock]
+## func startup(session: GdUnitTestSession) -> GdUnitResult:
+## # Access test cases
+## print("Running %d test cases" % session.test_cases.size())
+##
+## # Send status messages
+## session.send_message("Custom hook initialized")
+##
+## # Listen for test events
+## session.test_event.connect(_on_test_event)
+##
+## return GdUnitResult.success()
+##
+## func _on_test_event(event: GdUnitEvent) -> void:
+## print("Test event received: %s" % event.type)
+## [/codeblock]
+## [br]
+## [b][u]Event Flow[/u][/b][br]
+## 1. Session is created with a collection of test cases[br]
+## 2. Session connects to the global GdUnit event system[br]
+## 3. During test execution, events are automatically forwarded to session listeners[br]
+## 4. Hooks and other components can subscribe to session events[br]
+## 5. Messages can be sent through the session for logging and communication[br]
+class_name GdUnitTestSession
+extends RefCounted
+
+
+## Emitted when a test execution event occurs.[br]
+## [br]
+## [i]This signal forwards events from the global GdUnit event system to session-specific
+## listeners. It allows hooks and other session components to react to test events
+## without directly connecting to the global event system.[/i][br]
+## [br]
+## [u]Common event types include:[/u][br]
+## - Test suite start/end events[br]
+## - Test case start/end events[br]
+## - Test assertion events[br]
+## - Test failure/error events[br]
+##
+## [param event] The test event containing details about test execution, timing, and results
+@warning_ignore("unused_signal")
+signal test_event(event: GdUnitEvent)
+
+
+## [b][color=red]@readonly: Should not be modified directly during test execution![/color][/b][br]
+## Collection of test cases to be executed in this session.[br]
+## [br]
+## This array contains all the test cases that will be run during the session.
+## Test hooks can access this collection to:
+## - Get the total number of tests to be executed
+## - Access individual test case metadata
+## - Perform setup/teardown based on test case requirements
+## - Generate reports or statistics about the test suite
+##
+## The collection is typically populated before session startup and remains
+## constant during test execution.
+var _test_cases : Array[GdUnitTestCase] = []
+
+
+## [b][color=red]@readonly: The report path should not be modified after session creation![/color][/b][br]
+## The file system path where test reports for this session will be generated.[br]
+## [br]
+## [i]This property provides centralized access to the report output location,
+## allowing test hooks, reporters, and other components to reference the same
+## report path without coupling to specific reporter implementations.[/i][br]
+## [br]
+## [b][u]Common use cases include:[/u][/b][br]
+## - Test hooks generating additional report files in the same directory[br]
+## - Custom reporters creating supplementary output files[br]
+## - Post-processing scripts that need to locate generated reports[br]
+## - Cleanup operations that need to manage report artifacts[br]
+## [br]
+## [b][u]Example Usage:[/u][/b]
+## [codeblock]
+## # In a test hook
+## func startup(session: GdUnitTestSession) -> GdUnitResult:
+## var report_dir = session.report_path.get_base_dir()
+## var custom_report = report_dir.path_join("custom_metrics.json")
+## # Generate additional reports in the same location
+## return GdUnitResult.success()
+##
+## func shutdown(session: GdUnitTestSession) -> GdUnitResult:
+## session.send_message("Reports available at: " + session.report_path)
+## return GdUnitResult.success()
+## [/codeblock]
+## [br]
+## The path is set during session initialization and remains constant throughout
+## the test execution lifecycle.
+var report_path: String:
+ get:
+ return report_path
+
+
+## Initializes the test session and sets up event forwarding.[br]
+## [br]
+## [i]This constructor automatically connects to the global GdUnit event system
+## and forwards all events to the session's test_event signal. This allows
+## session-specific components to listen for test events without managing
+## global signal connections.[/i]
+func _init(test_cases: Array[GdUnitTestCase], session_report_path: String) -> void:
+ # We build a copy to prevent a user is modifing the tests
+ _test_cases = test_cases.duplicate(true)
+ report_path = session_report_path
+ GdUnitSignals.instance().gdunit_event.connect(func(event: GdUnitEvent) -> void:
+ test_event.emit(event)
+ )
+
+
+## Finds a test case by its unique identifier.[br]
+## [br]
+## [i]Searches through all test cases to find a test with the matching GUID.[/i][br]
+## [br]
+## [param id] The GUID of the test to find[br]
+## Returns the matching test case or null if not found.
+func find_test_by_id(id: GdUnitGUID) -> GdUnitTestCase:
+ for test in _test_cases:
+ if test.guid.equals(id):
+ return test
+
+ return null
+
+
+## Sends a message through the GdUnit messaging system.[br]
+## [br]
+## [i]This method provides a convenient way for test hooks and other session
+## components to send messages that will be handled by the GdUnit framework.[/i]
+## [br][br]
+## [b][u]Messages are typically used for:[/u][/b][br]
+## - Status updates during test execution[br]
+## - Progress reporting from test hooks[br]
+## - Debug information and logging[br]
+## - User notifications and alerts[br]
+## [br]
+## The message will be processed by the global GdUnit message system and
+## may be displayed in the test runner UI, logged to files, or handled
+## by other registered message handlers.
+## [br]
+## [b][u]Example Usage:[/u][/b]
+## [codeblock]
+## # In a test hook
+## func startup(session: GdUnitTestSession) -> GdUnitResult:
+## session.send_message("Database connection established")
+## return GdUnitResult.success()
+##
+## func shutdown(session: GdUnitTestSession) -> GdUnitResult:
+## session.send_message("Generated test report: report.html")
+## return GdUnitResult.success()
+## ```
+## [/codeblock]
+## [param message] The message text to send through the GdUnit messaging system
+func send_message(message: String) -> void:
+ GdUnitSignals.instance().gdunit_message.emit(message)
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid
new file mode 100644
index 0000000..aa729ff
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestSession.gd.uid
@@ -0,0 +1 @@
+uid://dj37sbm0qh3sj
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd
new file mode 100644
index 0000000..6551e08
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd
@@ -0,0 +1,180 @@
+extends Node
+## The base test runner implementation.[br]
+## [br]
+## This class provides the core functionality to execute test suites with following features:[br]
+## - Loading and initialization of test suites[br]
+## - Executing test suites and managing test states[br]
+## - Event dispatching and test reporting[br]
+## - Support for headless mode[br]
+## - Plugin version verification[br]
+## [br]
+## Supported by specialized runners:[br]
+## - [b]GdUnitTestRunner[/b]: Used in the editor, connects via tcp to report test results[br]
+## - [b]GdUnitCLRunner[/b]: A command line interface runner, writes test reports to file[br]
+## The test runner runs checked default in fail-fast mode, it stops checked first test failure.
+
+## Overall test run status codes used by the runners
+const RETURN_SUCCESS = 0
+const RETURN_ERROR = 100
+const RETURN_ERROR_HEADLESS_NOT_SUPPORTED = 103
+const RETURN_ERROR_GODOT_VERSION_NOT_SUPPORTED = 104
+const RETURN_WARNING = 101
+
+## Specifies the Node name under which the runner is registered
+const GDUNIT_RUNNER = "GdUnitRunner"
+
+## The current runner configuration
+@warning_ignore("unused_private_class_variable")
+var _runner_config := GdUnitRunnerConfig.new()
+
+## The test suite executor instance
+var _executor: GdUnitTestSuiteExecutor
+var _hooks : GdUnitTestSessionHookService
+
+## Current runner state
+var _state := READY
+
+## Current tests to be processed
+var _test_cases: Array[GdUnitTestCase] = []
+
+
+## Configured report base path (can be set on CI test runner)
+var report_base_path: String = GdUnitFileAccess.current_dir() + "reports":
+ get:
+ return report_base_path
+
+
+## Current session report path
+var report_path: String:
+ get:
+ return "%s/%s%d" % [report_base_path, GdUnitConstants.REPORT_DIR_PREFIX, current_report_history_index]
+
+
+## Current report history index, if max_report_history > 1 we scan for the next index over the existing reports
+var current_report_history_index: int:
+ get:
+ if max_report_history > 1:
+ return GdUnitFileAccess.find_last_path_index(report_base_path, GdUnitConstants.REPORT_DIR_PREFIX) + 1
+ else:
+ return 1
+
+
+## Controls how many report historys will be hold
+var max_report_history: int = GdUnitConstants.DEFAULT_REPORT_HISTORY_COUNT:
+ get:
+ return max_report_history
+ set(value):
+ max_report_history = value
+
+
+# holds the current test session context
+var _test_session: GdUnitTestSession
+
+## Runner state machine
+enum {
+ READY,
+ INIT,
+ RUN,
+ STOP,
+ EXIT
+}
+
+func _init() -> void:
+ if OS.get_cmdline_args().size() == 1:
+ DisplayServer.window_set_title("GdUnit4 Runner (Debug Mode)")
+ else:
+ DisplayServer.window_set_title("GdUnit4 Runner (Release Mode)")
+ if not Engine.is_embedded_in_editor():
+ # minimize scene window checked debug mode
+ DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
+ # store current runner instance to engine meta data to can be access in as a singleton
+ Engine.set_meta(GDUNIT_RUNNER, self)
+
+
+# Called when the node enters the scene tree for the first time.
+func _ready() -> void:
+ if Engine.get_version_info().hex < 0x40300:
+ printerr("The GdUnit4 plugin requires Godot version 4.3 or higher to run.")
+ quit(RETURN_ERROR_GODOT_VERSION_NOT_SUPPORTED)
+ return
+ _executor = GdUnitTestSuiteExecutor.new()
+
+ GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event)
+ _state = INIT
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE:
+ Engine.remove_meta(GDUNIT_RUNNER)
+
+
+## Main test runner loop. Is called every frame to manage the test execution.
+func _process(_delta: float) -> void:
+ match _state:
+ INIT:
+ await init_runner()
+ RUN:
+ _hooks = GdUnitTestSessionHookService.instance()
+ _test_session = GdUnitTestSession.new(_test_cases, report_path)
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionStart.new())
+ # process next test suite
+ set_process(false)
+ var result := await _hooks.execute_startup(_test_session)
+ if result.is_error():
+ push_error(result.error_message())
+ await _executor.run_and_wait(_test_cases)
+ result = await _hooks.execute_shutdown(_test_session)
+ if result.is_error():
+ push_error(result.error_message())
+ _state = STOP
+ set_process(true)
+ GdUnitSignals.instance().gdunit_event.emit(GdUnitSessionClose.new())
+ cleanup_report_history()
+ STOP:
+ _state = EXIT
+ # give the engine small amount time to finish the rpc
+ await get_tree().create_timer(0.1).timeout
+ await quit(get_exit_code())
+
+
+## Used by the inheriting runners to initialize test execution
+func init_runner() -> void:
+ await get_tree().process_frame
+
+
+func cleanup_report_history() -> int:
+ return GdUnitFileAccess.delete_path_index_lower_equals_than(
+ report_path.get_base_dir(),
+ GdUnitConstants.REPORT_DIR_PREFIX,
+ current_report_history_index-1-max_report_history)
+
+
+## Returns the exit code when the test run is finished.[br]
+## Abstract method to be implemented by the inheriting runners.
+func get_exit_code() -> int:
+ return RETURN_SUCCESS
+
+
+## Quits the test runner with given exit code.
+func quit(code: int) -> void:
+ await get_tree().process_frame
+ await get_tree().physics_frame
+ get_tree().quit(code)
+
+
+func prints_warning(message: String) -> void:
+ prints(message)
+
+
+## Default event handler to process test events.[br]
+## Should be overridden by concrete runner implementation.
+@warning_ignore("unused_parameter")
+func _on_gdunit_event(event: GdUnitEvent) -> void:
+ pass
+
+
+## Event bridge from C# GdUnit4.ITestEventListener.cs[br]
+## Used to handle test events from C# tests.
+# gdlint: disable=function-name
+func PublishEvent(data: Dictionary) -> void:
+ _on_gdunit_event(GdUnitEvent.new().deserialize(data))
diff --git a/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid
new file mode 100644
index 0000000..cfd20db
--- /dev/null
+++ b/addons/gdUnit4/src/core/runners/GdUnitTestSessionRunner.gd.uid
@@ -0,0 +1 @@
+uid://wnlvwy04lmn6
diff --git a/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd
new file mode 100644
index 0000000..20325ac
--- /dev/null
+++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd
@@ -0,0 +1,36 @@
+class_name GdUnitTestSuiteDefaultTemplate
+extends RefCounted
+
+
+const DEFAULT_TEMP_TS_GD ="""
+ # GdUnit generated TestSuite
+ class_name ${suite_class_name}
+ extends GdUnitTestSuite
+ @warning_ignore('unused_parameter')
+ @warning_ignore('return_value_discarded')
+
+ # TestSuite generated from
+ const __source: String = '${source_resource_path}'
+"""
+
+
+const DEFAULT_TEMP_TS_CS = """
+ // GdUnit generated TestSuite
+
+ using Godot;
+ using GdUnit4;
+
+ namespace ${name_space}
+ {
+ using static Assertions;
+ using static Utils;
+
+ [TestSuite]
+ public class ${suite_class_name}
+ {
+ // TestSuite generated from
+ private const string sourceClazzPath = "${source_resource_path}";
+
+ }
+ }
+"""
diff --git a/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid
new file mode 100644
index 0000000..af9b576
--- /dev/null
+++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteDefaultTemplate.gd.uid
@@ -0,0 +1 @@
+uid://d36rdpq1g8nt1
diff --git a/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd
new file mode 100644
index 0000000..6fc282d
--- /dev/null
+++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd
@@ -0,0 +1,144 @@
+class_name GdUnitTestSuiteTemplate
+extends RefCounted
+
+const TEMPLATE_ID_GD = 1000
+const TEMPLATE_ID_CS = 2000
+
+const SUPPORTED_TAGS_GD = """
+ GdScript Tags are replaced when the test-suite is created.
+
+ # The class name of the test-suite, formed from the source script.
+ ${suite_class_name}
+ # is used to build the test suite class name
+ class_name ${suite_class_name}
+ extends GdUnitTestSuite
+
+
+ # The class name in pascal case, formed from the source script.
+ ${source_class}
+ # can be used to create the class e.g. for source 'MyClass'
+ var my_test_class := ${source_class}.new()
+ # will be result in
+ var my_test_class := MyClass.new()
+
+ # The class as variable name in snake case, formed from the source script.
+ ${source_var}
+ # Can be used to build the variable name e.g. for source 'MyClass'
+ var ${source_var} := ${source_class}.new()
+ # will be result in
+ var my_class := MyClass.new()
+
+ # The full resource path from which the file was created.
+ ${source_resource_path}
+ # Can be used to load the script in your test
+ var my_script := load(${source_resource_path})
+ # will be result in
+ var my_script := load("res://folder/my_class.gd")
+"""
+
+const SUPPORTED_TAGS_CS = """
+ C# Tags are replaced when the test-suite is created.
+
+ // The namespace name of the test-suite
+ ${name_space}
+ namespace ${name_space}
+
+ // The class name of the test-suite, formed from the source class.
+ ${suite_class_name}
+ // is used to build the test suite class name
+ [TestSuite]
+ public class ${suite_class_name}
+
+ // The class name formed from the source class.
+ ${source_class}
+ // can be used to create the class e.g. for source 'MyClass'
+ private string myTestClass = new ${source_class}();
+ // will be result in
+ private string myTestClass = new MyClass();
+
+ // The class as variable name in camelCase, formed from the source class.
+ ${source_var}
+ // Can be used to build the variable name e.g. for source 'MyClass'
+ private object ${source_var} = new ${source_class}();
+ // will be result in
+ private object myClass = new MyClass();
+
+ // The full resource path from which the file was created.
+ ${source_resource_path}
+ // Can be used to load the script in your test
+ private object myScript = GD.Load(${source_resource_path});
+ // will be result in
+ private object myScript = GD.Load("res://folder/MyClass.cs");
+"""
+
+const TAG_TEST_SUITE_CLASS = "${suite_class_name}"
+const TAG_SOURCE_CLASS_NAME = "${source_class}"
+const TAG_SOURCE_CLASS_VARNAME = "${source_var}"
+const TAG_SOURCE_RESOURCE_PATH = "${source_resource_path}"
+
+
+static func default_GD_template() -> String:
+ return GdUnitTestSuiteDefaultTemplate.DEFAULT_TEMP_TS_GD.dedent().trim_prefix("\n")
+
+
+static func default_CS_template() -> String:
+ return GdUnitTestSuiteDefaultTemplate.DEFAULT_TEMP_TS_CS.dedent().trim_prefix("\n")
+
+
+static func build_template(source_path: String) -> String:
+ var clazz_name :String = GdObjects.to_pascal_case(GdObjects.extract_class_name(source_path).value_as_string())
+ var template: String = GdUnitSettings.get_setting(GdUnitSettings.TEMPLATE_TS_GD, default_GD_template())
+
+ return template\
+ .replace(TAG_TEST_SUITE_CLASS, clazz_name+"Test")\
+ .replace(TAG_SOURCE_RESOURCE_PATH, source_path)\
+ .replace(TAG_SOURCE_CLASS_NAME, clazz_name)\
+ .replace(TAG_SOURCE_CLASS_VARNAME, GdObjects.to_snake_case(clazz_name))
+
+
+static func default_template(template_id :int) -> String:
+ if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS:
+ push_error("Invalid template '%d' id! Cant load testsuite template" % template_id)
+ return ""
+ if template_id == TEMPLATE_ID_GD:
+ return default_GD_template()
+ return default_CS_template()
+
+
+static func load_template(template_id :int) -> String:
+ if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS:
+ push_error("Invalid template '%d' id! Cant load testsuite template" % template_id)
+ return ""
+ if template_id == TEMPLATE_ID_GD:
+ return GdUnitSettings.get_setting(GdUnitSettings.TEMPLATE_TS_GD, default_GD_template())
+ return GdUnitSettings.get_setting(GdUnitSettings.TEMPLATE_TS_CS, default_CS_template())
+
+
+static func save_template(template_id :int, template :String) -> void:
+ if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS:
+ push_error("Invalid template '%d' id! Cant load testsuite template" % template_id)
+ return
+ if template_id == TEMPLATE_ID_GD:
+ GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_GD, template.dedent().trim_prefix("\n"))
+ elif template_id == TEMPLATE_ID_CS:
+ GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_CS, template.dedent().trim_prefix("\n"))
+
+
+static func reset_to_default(template_id :int) -> void:
+ if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS:
+ push_error("Invalid template '%d' id! Cant load testsuite template" % template_id)
+ return
+ if template_id == TEMPLATE_ID_GD:
+ GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_GD, default_GD_template())
+ else:
+ GdUnitSettings.save_property(GdUnitSettings.TEMPLATE_TS_CS, default_CS_template())
+
+
+static func load_tags(template_id :int) -> String:
+ if template_id != TEMPLATE_ID_GD and template_id != TEMPLATE_ID_CS:
+ push_error("Invalid template '%d' id! Cant load testsuite template" % template_id)
+ return "Error checked loading tags"
+ if template_id == TEMPLATE_ID_GD:
+ return SUPPORTED_TAGS_GD
+ else:
+ return SUPPORTED_TAGS_CS
diff --git a/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid
new file mode 100644
index 0000000..e901a3d
--- /dev/null
+++ b/addons/gdUnit4/src/core/templates/test_suite/GdUnitTestSuiteTemplate.gd.uid
@@ -0,0 +1 @@
+uid://b2v71bhkym4ji
diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd
new file mode 100644
index 0000000..402199b
--- /dev/null
+++ b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd
@@ -0,0 +1,70 @@
+class_name GdUnitThreadContext
+extends RefCounted
+
+var _thread: Thread
+var _thread_name: String
+var _thread_id: int
+var _signal_collector: GdUnitSignalCollector
+var _execution_context: GdUnitExecutionContext
+var _asserts := []
+
+
+func _init(thread: Thread = null) -> void:
+ if thread != null:
+ _thread = thread
+ _thread_name = thread.get_meta("name")
+ _thread_id = thread.get_id() as int
+ else:
+ _thread_name = "main"
+ _thread_id = OS.get_main_thread_id()
+ _signal_collector = GdUnitSignalCollector.new()
+
+
+func dispose() -> void:
+ clear_assert()
+ if is_instance_valid(_signal_collector):
+ _signal_collector.clear()
+ _signal_collector = null
+ _execution_context = null
+ _thread = null
+
+
+func terminate() -> void:
+ _execution_context.terminate()
+
+
+func clear_assert() -> void:
+ _asserts.clear()
+
+
+func set_assert(value: GdUnitAssert) -> void:
+ if value != null:
+ _asserts.append(value)
+
+
+func get_assert() -> GdUnitAssert:
+ return null if _asserts.is_empty() else _asserts[-1]
+
+
+func set_execution_context(context: GdUnitExecutionContext) -> void:
+ _execution_context = context
+
+
+func get_execution_context() -> GdUnitExecutionContext:
+ return _execution_context
+
+
+func get_execution_context_id() -> int:
+ return _execution_context.get_instance_id()
+
+
+func get_signal_collector() -> GdUnitSignalCollector:
+ return _signal_collector
+
+
+func thread_id() -> int:
+ return _thread_id
+
+
+func _to_string() -> String:
+ return "ThreadContext <%s>: %s " % [_thread_name, _thread_id]
diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid
new file mode 100644
index 0000000..01dc8f6
--- /dev/null
+++ b/addons/gdUnit4/src/core/thread/GdUnitThreadContext.gd.uid
@@ -0,0 +1 @@
+uid://c8t88hv3p8rvu
diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd
new file mode 100644
index 0000000..2e694e2
--- /dev/null
+++ b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd
@@ -0,0 +1,69 @@
+## A manager to run new thread and crate a ThreadContext shared over the actual test run
+class_name GdUnitThreadManager
+extends Object
+
+## { <thread_id> = <GdUnitThreadContext> }
+var _thread_context_by_id: Dictionary[int, GdUnitThreadContext] = {}
+## holds the current thread id
+var _current_thread_id :int = -1
+
+func _init() -> void:
+ # add initail the main thread
+ _current_thread_id = OS.get_thread_caller_id()
+ _thread_context_by_id[OS.get_main_thread_id()] = GdUnitThreadContext.new()
+
+
+static func instance() -> GdUnitThreadManager:
+ return GdUnitSingleton.instance("GdUnitThreadManager", func() -> GdUnitThreadManager: return GdUnitThreadManager.new())
+
+
+## Runs a new thread by given name and Callable.[br]
+## A new GdUnitThreadContext is created, which is used for the actual test execution.[br]
+## We need this custom implementation while this bug is not solved
+## Godot issue https://github.com/godotengine/godot/issues/79637
+static func run(name :String, cb :Callable) -> Variant:
+ return await instance()._run(name, cb)
+
+
+static func interrupt() -> void:
+ for thread_context: GdUnitThreadContext in instance()._thread_context_by_id.values():
+ thread_context.terminate()
+
+
+## Returns the current valid thread context
+static func get_current_context() -> GdUnitThreadContext:
+ return instance()._get_current_context()
+
+
+func _run(name :String, cb :Callable) -> Variant:
+ # we do this hack because of `OS.get_thread_caller_id()` not returns the current id
+ # when await process_frame is called inside the fread
+ var save_current_thread_id := _current_thread_id
+ var thread := Thread.new()
+ thread.set_meta("name", name)
+ @warning_ignore("return_value_discarded")
+ thread.start(cb)
+ _current_thread_id = thread.get_id() as int
+ _register_thread(thread, _current_thread_id)
+ var result :Variant = await thread.wait_to_finish()
+ _unregister_thread(_current_thread_id)
+ # restore original thread id
+ _current_thread_id = save_current_thread_id
+ return result
+
+
+func _register_thread(thread :Thread, thread_id :int) -> void:
+ var context := GdUnitThreadContext.new(thread)
+ _thread_context_by_id[thread_id] = context
+
+
+func _unregister_thread(thread_id :int) -> void:
+ var context: GdUnitThreadContext = _thread_context_by_id.get(thread_id)
+ if context:
+ @warning_ignore("return_value_discarded")
+ _thread_context_by_id.erase(thread_id)
+ context.dispose()
+
+
+func _get_current_context() -> GdUnitThreadContext:
+ return _thread_context_by_id.get(_current_thread_id)
diff --git a/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid
new file mode 100644
index 0000000..b943f92
--- /dev/null
+++ b/addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd.uid
@@ -0,0 +1 @@
+uid://7j6xocp0kaix
diff --git a/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd
new file mode 100644
index 0000000..cc93ee2
--- /dev/null
+++ b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd
@@ -0,0 +1,227 @@
+@tool
+class_name GdUnitCSIMessageWriter
+extends GdUnitMessageWriter
+## A message writer implementation using ANSI/CSI escape codes for console output.[br]
+## [br]
+## This writer provides formatted message output using CSI (Control Sequence Introducer) codes.[br]
+## It supports:[br]
+## - Color using RGB values[br]
+## - Text styles (bold, italic, underline)[br]
+## - Cursor positioning and text alignment[br]
+## [br]
+## Used primarily for console-based test execution and CI/CD environments.
+
+
+enum {
+ COLOR_TABLE,
+ COLOR_RGB
+}
+
+const CSI_BOLD = ""
+const CSI_ITALIC = ""
+const CSI_UNDERLINE = ""
+const CSI_RESET = ""
+
+# Control Sequence Introducer
+var _debug_show_color_codes := false
+var _color_mode := COLOR_TABLE
+
+## Current cursor position in the line
+var _current_pos := 0
+
+# Pre-compiled regex patterns for tag matching
+var _tag_regex: RegEx
+
+
+## Constructs CSI style codes based on flags.[br]
+## [br]
+## [param flags] The style flags to apply (BOLD, ITALIC, UNDERLINE).[br]
+## Returns the corresponding CSI codes.
+func _apply_style_flags(flags: int) -> String:
+ var _style := ""
+ if flags & BOLD:
+ _style += CSI_BOLD
+ if flags & ITALIC:
+ _style += CSI_ITALIC
+ if flags & UNDERLINE:
+ _style += CSI_UNDERLINE
+ return _style
+
+
+## Converts a color string (named or hex) to a Color object
+func _parse_color(color_str: String) -> Color:
+ return Color.from_string(color_str.strip_edges().to_lower(), Color.WHITE)
+
+
+## Generates CSI color code for foreground color
+func _color_to_csi_fg(c: Color) -> String:
+ return "[38;2;%d;%d;%dm" % [c.r8 * c.a, c.g8 * c.a, c.b8 * c.a]
+
+
+## Generates CSI color code for background color
+func _color_to_csi_bg(c: Color) -> String:
+ return "[48;2;%d;%d;%dm" % [c.r8 * c.a, c.g8 * c.a, c.b8 * c.a]
+
+
+func _init_regex_patterns() -> void:
+ if not _tag_regex:
+ _tag_regex = RegEx.new()
+ # Match all richtext tags: [tag], [tag=value], [/tag]
+ _tag_regex.compile(r"\[/?(?:color|bgcolor|b|i|u)(?:=[^\]]+)?\]")
+
+
+func _extract_color_from_tag(tag: String, tag_assign: String) -> Color:
+ var tag_assign_length := tag_assign.length()
+ var color_value := tag.substr(tag_assign_length, tag.length() - tag_assign_length - 1)
+ return _parse_color(color_value)
+
+
+## Optimized richtext to CSI conversion using regex and lookup processing
+func _bbcode_tags_to_csi_codes(message: String) -> String:
+ _init_regex_patterns()
+
+ var result := ""
+ var last_pos := 0
+ var color_stack: Array[Color] = []
+ var bgcolor_stack: Array[Color] = []
+
+ # Find all richtext tags
+ var matches := _tag_regex.search_all(message)
+
+ for match in matches:
+ var start_pos := match.get_start()
+ var end_pos := match.get_end()
+ var tag := match.get_string(0)
+
+ # Add text before this tag
+ result += message.substr(last_pos, start_pos - last_pos)
+
+ # Process the tag
+ if tag.begins_with("[color="):
+ var fg_color := _extract_color_from_tag(tag, "[color=")
+ color_stack.push_back(fg_color)
+ result += _color_to_csi_fg(fg_color)
+ elif tag.begins_with("[bgcolor="):
+ var bg_color := _extract_color_from_tag(tag, "[bgcolor=")
+ bgcolor_stack.push_back(bg_color)
+ result += _color_to_csi_bg(bg_color)
+ elif tag == "[b]":
+ result += CSI_BOLD
+ elif tag == "[i]":
+ result += CSI_ITALIC
+ elif tag == "[u]":
+ result += CSI_UNDERLINE
+ elif tag == "[/color]":
+ result += CSI_RESET
+ if color_stack.size() > 0:
+ color_stack.pop_back()
+ # Restore remaining styles and colors
+ if color_stack.size() > 0:
+ result += _color_to_csi_fg(color_stack[-1])
+ if bgcolor_stack.size() > 0:
+ result += _color_to_csi_bg(bgcolor_stack[-1])
+ elif tag == "[/bgcolor]":
+ result += CSI_RESET
+ if bgcolor_stack.size() > 0:
+ bgcolor_stack.pop_back()
+ # Restore remaining styles and colors
+ if color_stack.size() > 0:
+ result += _color_to_csi_fg(color_stack[-1])
+ if bgcolor_stack.size() > 0:
+ result += _color_to_csi_bg(bgcolor_stack[-1])
+ elif tag in ["[/b]", "[/i]", "[/u]"]:
+ result += CSI_RESET
+ # Restore remaining colors after style reset
+ if color_stack.size() > 0:
+ result += _color_to_csi_fg(color_stack[-1])
+ if bgcolor_stack.size() > 0:
+ result += _color_to_csi_bg(bgcolor_stack[-1])
+
+ last_pos = end_pos
+
+ # Add remaining text after last tag
+ result += message.substr(last_pos)
+
+ return result
+
+
+## Implementation of basic message output with formatting.
+func _print_message(_message: String, _color: Color, _indent: int, _flags: int) -> void:
+ var text := _bbcode_tags_to_csi_codes(_message)
+ var indent_text := "".lpad(_indent * 2)
+ var _style := _apply_style_flags(_flags)
+ printraw("%s[38;2;%d;%d;%dm%s%s" % [indent_text, _color.r8, _color.g8, _color.b8, _style, text])
+ _current_pos += _indent * 2 + text.length()
+
+
+## Implementation of line-ending message output with formatting.
+func _println_message(_message: String, _color: Color, _indent: int, _flags: int) -> void:
+ _print_message(_message, _color, _indent, _flags)
+ prints()
+ _current_pos = 0
+
+
+## Implementation of positioned message output with formatting.
+func _print_at(_message: String, cursor_pos: int, _color: Color, _effect: GdUnitMessageWriter.Effect, _align: Align, _flags: int) -> void:
+ if _align == Align.RIGHT:
+ cursor_pos = cursor_pos - _message.length()
+
+ if cursor_pos > _current_pos:
+ printraw("[%dG" % cursor_pos) # Move cursor to absolute position
+ else:
+ _message = " " + _message
+
+ var _style := _apply_style_flags(_flags)
+ printraw("[38;2;%d;%d;%dm%s%s" % [_color.r8, _color.g8, _color.b8, _style, _message])
+ _current_pos = cursor_pos + _message.length()
+
+
+## Writes a line break and returns self for chaining.
+func new_line() -> GdUnitCSIMessageWriter:
+ prints()
+ return self
+
+
+## Saves the current cursor position.[br]
+## Returns self for chaining.
+func save_cursor() -> GdUnitCSIMessageWriter:
+ printraw("")
+ return self
+
+
+## Restores previously saved cursor position.[br]
+## Returns self for chaining.
+func restore_cursor() -> GdUnitCSIMessageWriter:
+ printraw("")
+ return self
+
+
+## Clears screen content and resets cursor position.
+func clear() -> void:
+ printraw("") # Clear screen and move cursor to home
+ _current_pos = 0
+
+
+## Debug method to display the available color table.[br]
+## Shows both 6x6x6 color cube and RGB color modes.
+@warning_ignore("return_value_discarded")
+func _print_color_table() -> void:
+ color(Color.ANTIQUE_WHITE).println_message("Color Table 6x6x6")
+ _debug_show_color_codes = true
+ for green in range(0, 6):
+ for red in range(0, 6):
+ for blue in range(0, 6):
+ color(Color8(red * 42, green * 42, blue * 42)).println_message("████████ ")
+ new_line()
+ new_line()
+
+ color(Color.ANTIQUE_WHITE).println_message("Color Table RGB")
+ _color_mode = COLOR_RGB
+ for green in range(0, 6):
+ for red in range(0, 6):
+ for blue in range(0, 6):
+ color(Color8(red * 42, green * 42, blue * 42)).println_message("████████ ")
+ new_line()
+ new_line()
+ _color_mode = COLOR_TABLE
+ _debug_show_color_codes = false
diff --git a/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid
new file mode 100644
index 0000000..e87dda7
--- /dev/null
+++ b/addons/gdUnit4/src/core/writers/GdUnitCSIMessageWriter.gd.uid
@@ -0,0 +1 @@
+uid://bch3bjbfhr5lc
diff --git a/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd
new file mode 100644
index 0000000..5244efa
--- /dev/null
+++ b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd
@@ -0,0 +1,214 @@
+@tool
+class_name GdUnitMessageWriter
+extends RefCounted
+## Base interface class for writing formatted messages to different outputs.[br]
+## [br]
+## This class defines the interface and common functionality for writing formatted messages.[br]
+## It provides a fluent API for message formatting and supports different output targets.[br]
+## [br]
+## The class provides formatting options for:[br]
+## - Text colors[br]
+## - Text styles (bold, italic, underline)[br]
+## - Text effects (e.g., wave)[br]
+## - Text alignment[br]
+## - Indentation[br]
+## [br]
+## Two concrete implementations are available:[br]
+## - [GdUnitRichTextMessageWriter] writing to a [RichTextLabel][br]
+## - [GdUnitCSIMessageWriter] writing to console using CSI codes[br]
+## [br]
+## Example usage:[br]
+## [codeblock]
+## writer.color(Color.RED).style(BOLD).println_message("Test failed!")
+## writer.color(Color.GREEN).align(Align.RIGHT).print_at("Success", 80)
+## [/codeblock]
+
+
+## Text style flag for bold formatting
+const BOLD = 0x1
+## Text style flag for italic formatting
+const ITALIC = 0x2
+## Text style flag for underline formatting
+const UNDERLINE = 0x4
+
+
+## Represents special text effects that can be applied to the output
+enum Effect {
+ ## No special effect applied
+ NONE,
+ ## Applies a wave animation to the text
+ WAVE
+}
+
+
+## Controls text alignment at the specified cursor position
+enum Align {
+ ## Aligns text to the left of the cursor position
+ LEFT,
+ ## Aligns text to the right of the cursor position, accounting for text length
+ RIGHT
+}
+
+
+## The current text color to be used for the next output operation
+var _current_color := Color.WHITE
+
+## The current indentation level to be used for the next output operation.[br]
+## Each level represents two spaces of indentation.
+var _current_indent := 0
+
+## The current text style flags (BOLD, ITALIC, UNDERLINE) to be used for the next output operation
+var _current_flags := 0
+
+## The current text alignment to be used for the next output operation
+var _current_align := Align.LEFT
+
+## The current text effect to be used for the next output operation
+var _current_effect := GdUnitMessageWriter.Effect.NONE
+
+
+## Sets the text color for the next output operation.[br]
+## [br]
+## [param value] The color to be used for the text.
+## Returns self for method chaining.
+func color(value: Color) -> GdUnitMessageWriter:
+ _current_color = value
+ return self
+
+
+## Sets the indentation level for the next output operation.[br]
+## [br]
+## [param value] The number of indentation levels, where each level equals two spaces.
+## Returns self for method chaining.
+func indent(value: int) -> GdUnitMessageWriter:
+ _current_indent = value
+ return self
+
+
+## Sets text style flags for the next output operation.[br]
+## [br]
+## [param value] A combination of style flags (BOLD, ITALIC, UNDERLINE).
+## Returns self for method chaining.
+func style(value: int) -> GdUnitMessageWriter:
+ _current_flags = value
+ return self
+
+
+## Sets text effect for the next output operation.[br]
+## [br]
+## [param value] The effect to apply to the text (NONE, WAVE).
+## Returns self for method chaining.
+func effect(value: GdUnitMessageWriter.Effect) -> GdUnitMessageWriter:
+ _current_effect = value
+ return self
+
+
+## Sets text alignment for the next output operation.[br]
+## [br]
+## [param value] The alignment to use (LEFT, RIGHT).
+## Returns self for method chaining.
+func align(value: Align) -> GdUnitMessageWriter:
+ _current_align = value
+ return self
+
+
+## Resets all formatting options to their default values.[br]
+## [br]
+## Defaults:[br]
+## - color: Color.WHITE[br]
+## - indent: 0[br]
+## - flags: 0[br]
+## - align: LEFT[br]
+## - effect: NONE[br]
+## Returns self for method chaining.
+func reset() -> GdUnitMessageWriter:
+ _current_color = Color.WHITE
+ _current_indent = 0
+ _current_flags = 0
+ _current_align = Align.LEFT
+ _current_effect = Effect.NONE
+ return self
+
+
+## Prints a warning message in golden color.[br]
+## [br]
+## [param message] The warning message to print.
+func prints_warning(message: String) -> void:
+ color(Color.GOLDENROD).println_message(message)
+
+
+## Prints an error message in crimson color.[br]
+## [br]
+## [param message] The error message to print.
+func prints_error(message: String) -> void:
+ color(Color.CRIMSON).println_message(message)
+
+
+## Prints a message with current formatting settings.[br]
+## [br]
+## [param message] The text to print.
+func print_message(message: String) -> void:
+ _print_message(message, _current_color, _current_indent, _current_flags)
+ reset()
+
+
+## Prints a message with current formatting settings followed by a newline.[br]
+## [br]
+## [param message] The text to print.
+func println_message(message: String) -> void:
+ _println_message(message, _current_color, _current_indent, _current_flags)
+ reset()
+
+
+## Prints a message at a specific column position with current formatting settings.[br]
+## [br]
+## [param message] The text to print.[br]
+## [param cursor_pos] The column position where the text should start.
+func print_at(message: String, cursor_pos: int) -> void:
+ _print_at(message, cursor_pos, _current_color, _current_effect, _current_align, _current_flags)
+ reset()
+
+
+## Internal implementation of print_message.[br]
+## [br]
+## To be overridden by concrete formatters.[br]
+## [br]
+## [param message] The text to print.[br]
+## [param color] The color to use.[br]
+## [param indent] The indentation level.[br]
+## [param flags] The style flags to apply.
+func _print_message(_message: String, _color: Color, _indent: int, _flags: int) -> void:
+ pass
+
+
+## Internal implementation of println_message.[br]
+## [br]
+## To be overridden by concrete formatters.[br]
+## [br]
+## [param message] The text to print.[br]
+## [param color] The color to use.[br]
+## [param indent] The indentation level.[br]
+## [param flags] The style flags to apply.
+func _println_message(_message: String, _color: Color, _indent: int, _flags: int) -> void:
+ pass
+
+
+## Internal implementation of print_at.[br]
+## [br]
+## To be overridden by concrete formatters.[br]
+## [br]
+## [param message] The text to print.[br]
+## [param cursor_pos] The column position.[br]
+## [param color] The color to use.[br]
+## [param effect] The effect to apply.[br]
+## [param align] The text alignment.[br]
+## [param flags] The style flags to apply.
+func _print_at(_message: String, _cursor_pos: int, _color: Color, _effect: GdUnitMessageWriter.Effect, _align: Align, _flags: int) -> void:
+ pass
+
+
+## Clears all output content.[br]
+## [br]
+## To be overridden by concrete formatters.
+func clear() -> void:
+ pass
diff --git a/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid
new file mode 100644
index 0000000..0fe00d2
--- /dev/null
+++ b/addons/gdUnit4/src/core/writers/GdUnitMessageWriter.gd.uid
@@ -0,0 +1 @@
+uid://5etcu5juhye8
diff --git a/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd
new file mode 100644
index 0000000..b9f79e4
--- /dev/null
+++ b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd
@@ -0,0 +1,115 @@
+@tool
+class_name GdUnitRichTextMessageWriter
+extends GdUnitMessageWriter
+## A message writer implementation using [RichTextLabel] for the test report UI.[br]
+## [br]
+## This writer implementation writes formatted messages to a [RichTextLabel] using BBCode.[br]
+## It supports:[br]
+## - Text formatting using BBCode (bold, italic, underline)[br]
+## - Text coloring using push colors[br]
+## - Text indentation using push indent[br]
+## - Text effects like wave[br]
+## - Basic cursor positioning[br]
+## [br]
+## Used to format test reports in the editor UI.
+
+
+## The [RichTextLabel] instance to write formatted messages
+var _output: RichTextLabel
+
+## Tracks current position in characters from line start
+var _current_pos := 0
+
+
+## Creates a new message writer for the given [RichTextLabel].[br]
+## [br]
+## [param output] The [RichTextLabel] used for output.
+func _init(output: RichTextLabel) -> void:
+ _output = output
+
+
+## Applies text style flags by wrapping text in BBCode tags.[br]
+## [br]
+## Available styles:[br]
+## - BOLD: [b]text[/b][br]
+## - ITALIC: [i]text[/i][br]
+## - UNDERLINE: [u]text[/u][br]
+## [br]
+## [param message] The text to format.[br]
+## [param flags] The text style flags to apply.
+func _apply_flags(message: String, flags: int) -> String:
+ if flags & BOLD:
+ message = "[b]%s[/b]" % message
+ if flags & ITALIC:
+ message = "[i]%s[/i]" % message
+ if flags & UNDERLINE:
+ message = "[u]%s[/u]" % message
+ return message
+
+
+## Writes a message with formatting.[br]
+## [br]
+## [param message] The text to write.[br]
+## [param _color] The color to use.[br]
+## [param _indent] The indentation level.[br]
+## [param flags] The text style flags to apply.
+func _print_message(message: String, _color: Color, _indent: int, flags: int) -> void:
+ for i in _indent:
+ _output.push_indent(1)
+ _output.push_color(_color)
+ message = _apply_flags(message, flags)
+ _output.append_text(message)
+ _output.pop()
+ for i in _indent:
+ _output.pop()
+ _current_pos += _indent * 2 + message.length()
+
+
+## Writes a message with formatting followed by a line break.[br]
+## [br]
+## [param message] The text to write.[br]
+## [param _color] The color to use.[br]
+## [param _indent] The indentation level.[br]
+## [param flags] The text style flags to apply.
+func _println_message(message: String, _color: Color, _indent: int, flags: int) -> void:
+ _print_message(message, _color, _indent, flags)
+ _output.newline()
+ _current_pos = 0
+
+
+## Writes a message at a specific column position.[br]
+## [br]
+## [param message] The text to write.[br]
+## [param cursor_pos] The column position from line start.[br]
+## [param _color] The color to use.[br]
+## [param _effect] The text effect to apply (e.g. wave).[br]
+## [param _align] The text alignment (left or right).[br]
+## [param flags] The text style flags to apply.
+func _print_at(message: String, cursor_pos: int, _color: Color, _effect: GdUnitMessageWriter.Effect, _align: Align, flags: int) -> void:
+ if _align == Align.RIGHT:
+ cursor_pos = cursor_pos - message.length()
+
+ var spaces := cursor_pos - _current_pos
+ if spaces > 0:
+ _output.append_text("".lpad(spaces))
+ _current_pos += spaces
+ else:
+ _output.append_text(" ")
+ _current_pos += 1
+
+ _output.push_color(_color)
+ message = _apply_flags(message, flags)
+ match _effect:
+ Effect.NONE:
+ pass
+ Effect.WAVE:
+ message = "[wave]%s[/wave]" % message
+ _output.append_text(message)
+ _output.pop()
+ _current_pos += message.length()
+
+
+## Clears all written content from the [RichTextLabel].
+func clear() -> void:
+ _output.clear()
+ _current_pos = 0
diff --git a/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid
new file mode 100644
index 0000000..23fe6c0
--- /dev/null
+++ b/addons/gdUnit4/src/core/writers/GdUnitRichTextMessageWriter.gd.uid
@@ -0,0 +1 @@
+uid://cn0dawecdtlqx
diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs
new file mode 100644
index 0000000..096e83c
--- /dev/null
+++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs
@@ -0,0 +1,235 @@
+// Copyright (c) 2025 Mike Schulze
+// MIT License - See LICENSE file in the repository root for full license text
+#pragma warning disable IDE1006
+namespace gdUnit4.addons.gdUnit4.src.dotnet;
+#pragma warning restore IDE1006
+
+#if GDUNIT4NET_API_V5
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using GdUnit4;
+using GdUnit4.Api;
+
+using Godot;
+using Godot.Collections;
+
+/// <summary>
+/// The GdUnit4 GDScript - C# API wrapper.
+/// </summary>
+public partial class GdUnit4CSharpApi : RefCounted
+{
+ /// <summary>
+ /// The signal to be emitted when the execution is completed.
+ /// </summary>
+ [Signal]
+#pragma warning disable CA1711
+ public delegate void ExecutionCompletedEventHandler();
+#pragma warning restore CA1711
+
+#pragma warning disable CA2213, SA1201
+ private CancellationTokenSource? executionCts;
+#pragma warning restore CA2213, SA1201
+
+ /// <summary>
+ /// Indicates if the API loaded.
+ /// </summary>
+ /// <returns>Returns true if the API already loaded.</returns>
+ public static bool IsApiLoaded()
+ => true;
+
+ /// <summary>
+ /// Runs test discovery on the given script.
+ /// </summary>
+ /// <param name="sourceScript">The script to be scanned.</param>
+ /// <returns>The list of tests discovered as dictionary.</returns>
+ public static Array<Dictionary> DiscoverTests(CSharpScript sourceScript)
+ {
+ try
+ {
+ // Get the list of test case descriptors from the API
+ var testCaseDescriptors = GdUnit4NetApiGodotBridge.DiscoverTestsFromScript(sourceScript);
+
+ // Convert each TestCaseDescriptor to a Dictionary
+ return testCaseDescriptors
+ .Select(descriptor => new Dictionary
+ {
+ ["guid"] = descriptor.Id.ToString(),
+ ["managed_type"] = descriptor.ManagedType,
+ ["test_name"] = descriptor.ManagedMethod,
+ ["source_file"] = sourceScript.ResourcePath,
+ ["line_number"] = descriptor.LineNumber,
+ ["attribute_index"] = descriptor.AttributeIndex,
+ ["require_godot_runtime"] = descriptor.RequireRunningGodotEngine,
+ ["code_file_path"] = descriptor.CodeFilePath ?? string.Empty,
+ ["simple_name"] = descriptor.SimpleName,
+ ["fully_qualified_name"] = descriptor.FullyQualifiedName,
+ ["assembly_location"] = descriptor.AssemblyPath
+ })
+ .Aggregate(
+ new Array<Dictionary>(),
+ (array, dict) =>
+ {
+ array.Add(dict);
+ return array;
+ });
+ }
+#pragma warning disable CA1031
+ catch (Exception e)
+#pragma warning restore CA1031
+ {
+ GD.PrintErr($"Error discovering tests: {e.Message}\n{e.StackTrace}");
+#pragma warning disable IDE0028 // Do not catch general exception types
+ return new Array<Dictionary>();
+#pragma warning restore IDE0028 // Do not catch general exception types
+ }
+ }
+
+ /// <summary>
+ /// Creates a test suite based on the specified source path and line number.
+ /// </summary>
+ /// <param name="sourcePath">The path to the source file from which to create the test suite.</param>
+ /// <param name="lineNumber">The line number in the source file where the method to test is defined.</param>
+ /// <param name="testSuitePath">The path where the test suite should be created.</param>
+ /// <returns>A dictionary containing information about the created test suite.</returns>
+ public static Dictionary CreateTestSuite(string sourcePath, int lineNumber, string testSuitePath)
+ => GdUnit4NetApiGodotBridge.CreateTestSuite(sourcePath, lineNumber, testSuitePath);
+
+ /// <summary>
+ /// Gets the version of the GdUnit4 assembly.
+ /// </summary>
+ /// <returns>The version string of the GdUnit4 assembly.</returns>
+ public static string Version()
+ => GdUnit4NetApiGodotBridge.Version();
+
+ /// <inheritdoc />
+ public override void _Notification(int what)
+ {
+ if (what != NotificationPredelete)
+ return;
+ executionCts?.Dispose();
+ executionCts = null;
+ }
+
+ /// <summary>
+ /// Executes the tests and using the listener for reporting the results.
+ /// </summary>
+ /// <param name="tests">A list of tests to be executed.</param>
+ /// <param name="listener">The listener to report the results.</param>
+ public void ExecuteAsync(Array<Dictionary> tests, Callable listener)
+ {
+ try
+ {
+ // Cancel any ongoing execution
+ executionCts?.Cancel();
+ executionCts?.Dispose();
+
+ // Create new cancellation token source
+ executionCts = new CancellationTokenSource();
+
+ Debug.Assert(tests != null, nameof(tests) + " != null");
+ var testSuiteNodes = new List<TestSuiteNode> { BuildTestSuiteNodeFrom(tests) };
+ GdUnit4NetApiGodotBridge.ExecuteAsync(testSuiteNodes, listener, executionCts.Token)
+ .GetAwaiter()
+ .OnCompleted(() => EmitSignal(SignalName.ExecutionCompleted));
+ }
+#pragma warning disable CA1031
+ catch (Exception e)
+#pragma warning restore CA1031
+ {
+ GD.PrintErr($"Error executing tests: {e.Message}\n{e.StackTrace}");
+ Task.Run(() => { }).GetAwaiter().OnCompleted(() => EmitSignal(SignalName.ExecutionCompleted));
+ }
+ }
+
+ /// <summary>
+ /// Will cancel the current test execution.
+ /// </summary>
+ public void CancelExecution()
+ {
+ try
+ {
+ executionCts?.Cancel();
+ }
+#pragma warning disable CA1031
+ catch (Exception e)
+#pragma warning restore CA1031
+ {
+ GD.PrintErr($"Error cancelling execution: {e.Message}");
+ }
+ }
+
+ // Convert a set of Tests stored as Dictionaries to TestSuiteNode
+ // all tests are assigned to a single test suit
+ internal static TestSuiteNode BuildTestSuiteNodeFrom(Array<Dictionary> tests)
+ {
+ if (tests.Count == 0)
+ throw new InvalidOperationException("Cant build 'TestSuiteNode' from an empty test set.");
+
+ // Create a suite ID
+ var suiteId = Guid.NewGuid();
+ var firstTest = tests[0];
+ var managedType = firstTest["managed_type"].AsString();
+ var assemblyLocation = firstTest["assembly_location"].AsString();
+ var sourceFile = firstTest["source_file"].AsString();
+
+ // Create TestCaseNodes for each test in the suite
+ var testCaseNodes = tests
+ .Select(test => new TestCaseNode
+ {
+ Id = Guid.Parse(test["guid"].AsString()),
+ ParentId = suiteId,
+ ManagedMethod = test["test_name"].AsString(),
+ LineNumber = test["line_number"].AsInt32(),
+ AttributeIndex = test["attribute_index"].AsInt32(),
+ RequireRunningGodotEngine = test["require_godot_runtime"].AsBool()
+ })
+ .ToList();
+
+ return new TestSuiteNode
+ {
+ Id = suiteId,
+ ParentId = Guid.Empty,
+ ManagedType = managedType,
+ AssemblyPath = assemblyLocation,
+ SourceFile = sourceFile,
+ Tests = testCaseNodes
+ };
+ }
+}
+#else
+using Godot;
+using Godot.Collections;
+
+public partial class GdUnit4CSharpApi : RefCounted
+{
+ [Signal]
+ public delegate void ExecutionCompletedEventHandler();
+
+ public static bool IsApiLoaded()
+ {
+ GD.PushWarning("No `gdunit4.api` dependency found, check your project dependencies.");
+ return false;
+ }
+
+
+ public static string Version()
+ => "Unknown";
+
+ public static Array<Dictionary> DiscoverTests(CSharpScript sourceScript) => new();
+
+ public void ExecuteAsync(Array<Dictionary> tests, Callable listener)
+ {
+ }
+
+ public static bool IsTestSuite(CSharpScript script)
+ => false;
+
+ public static Dictionary CreateTestSuite(string sourcePath, int lineNumber, string testSuitePath)
+ => new();
+}
+#endif
diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd
new file mode 100644
index 0000000..3d8ba25
--- /dev/null
+++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd
@@ -0,0 +1,114 @@
+## GdUnit4CSharpApiLoader
+##
+## A bridge class that handles communication between GDScript and C# for the GdUnit4 testing framework.
+## This loader acts as a compatibility layer to safely access the .NET API and ensure that calls
+## only proceed when the .NET environment is properly configured and available.
+## [br]
+## The class handles:
+## - Verification of .NET runtime availability
+## - Loading the C# wrapper script
+## - Checking for the GdUnit4Api assembly
+## - Providing proxy methods to access GdUnit4 functionality in C#
+@static_unload
+class_name GdUnit4CSharpApiLoader
+extends RefCounted
+
+## Cached reference to the loaded C# wrapper script
+static var _gdUnit4NetWrapper: Script
+
+## Cached instance of the API (singleton pattern)
+static var _api_instance: RefCounted
+
+
+class TestEventListener extends RefCounted:
+
+ func publish_event(event: Dictionary) -> void:
+ var test_event := GdUnitEvent.new().deserialize(event)
+ GdUnitSignals.instance().gdunit_event.emit(test_event)
+
+static var _test_event_listener := TestEventListener.new()
+
+
+## Returns an instance of the GdUnit4CSharpApi wrapper.[br]
+## @return Script: The loaded C# wrapper or null if .NET is not supported
+static func instance() -> Script:
+ if not GdUnit4CSharpApiLoader.is_api_loaded():
+ return null
+
+ return _gdUnit4NetWrapper
+
+
+## Returns or creates a single instance of the API [br]
+## This improves performance by reusing the same object
+static func api_instance() -> RefCounted:
+ if _api_instance == null and is_api_loaded():
+ @warning_ignore("unsafe_method_access")
+ _api_instance = instance().new()
+ return _api_instance
+
+
+static func is_engine_version_supported(engine_version: int = Engine.get_version_info().hex) -> bool:
+ return engine_version >= 0x40200
+
+
+## Checks if the .NET environment is properly configured and available.[br]
+## @return bool: True if .NET is fully supported and the assembly is found
+static func is_api_loaded() -> bool:
+ # If the wrapper is already loaded we don't need to check again
+ if _gdUnit4NetWrapper != null:
+ return true
+
+ # First we check if this is a Godot .NET runtime instance
+ if not ClassDB.class_exists("CSharpScript") or not is_engine_version_supported():
+ return false
+ # Second we check the C# project file exists
+ var assembly_name: String = ProjectSettings.get_setting("dotnet/project/assembly_name")
+ if assembly_name.is_empty() or not FileAccess.file_exists("res://%s.csproj" % assembly_name):
+ return false
+
+ # Finally load the wrapper and check if the GdUnit4 assembly can be found
+ _gdUnit4NetWrapper = load("res://addons/gdUnit4/src/dotnet/GdUnit4CSharpApi.cs")
+ @warning_ignore("unsafe_method_access")
+ return _gdUnit4NetWrapper.call("IsApiLoaded")
+
+
+## Returns the version of the GdUnit4 .NET assembly.[br]
+## @return String: The version string or "unknown" if .NET is not supported
+static func version() -> String:
+ if not GdUnit4CSharpApiLoader.is_api_loaded():
+ return "unknown"
+ @warning_ignore("unsafe_method_access")
+ return instance().Version()
+
+
+static func discover_tests(source_script: Script) -> Array[GdUnitTestCase]:
+ var tests: Array = _gdUnit4NetWrapper.call("DiscoverTests", source_script)
+
+ return Array(tests.map(GdUnitTestCase.from_dict), TYPE_OBJECT, "RefCounted", GdUnitTestCase)
+
+
+static func execute(tests: Array[GdUnitTestCase]) -> void:
+ var net_api := api_instance()
+ if net_api == null:
+ push_warning("Execute C# tests not supported!")
+ return
+ var tests_as_dict: Array[Dictionary] = Array(tests.map(GdUnitTestCase.to_dict), TYPE_DICTIONARY, "", null)
+
+ net_api.call("ExecuteAsync", tests_as_dict, _test_event_listener.publish_event)
+ @warning_ignore("unsafe_property_access")
+ await net_api.ExecutionCompleted
+
+
+static func create_test_suite(source_path: String, line_number: int, test_suite_path: String) -> GdUnitResult:
+ if not GdUnit4CSharpApiLoader.is_api_loaded():
+ return GdUnitResult.error("Can't create test suite. No .NET support found.")
+ @warning_ignore("unsafe_method_access")
+ var result: Dictionary = instance().CreateTestSuite(source_path, line_number, test_suite_path)
+ if result.has("error"):
+ return GdUnitResult.error(str(result.get("error")))
+ return GdUnitResult.success(result)
+
+
+static func is_csharp_file(resource_path: String) -> bool:
+ var ext := resource_path.get_extension()
+ return ext == "cs" and GdUnit4CSharpApiLoader.is_api_loaded()
diff --git a/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid
new file mode 100644
index 0000000..bca7e38
--- /dev/null
+++ b/addons/gdUnit4/src/dotnet/GdUnit4CSharpApiLoader.gd.uid
@@ -0,0 +1 @@
+uid://b8y4p27r37pk1
diff --git a/addons/gdUnit4/src/doubler/CallableDoubler.gd b/addons/gdUnit4/src/doubler/CallableDoubler.gd
new file mode 100644
index 0000000..1bc78a1
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/CallableDoubler.gd
@@ -0,0 +1,156 @@
+## The helper class to allow to double Callable
+## Is just a wrapper to the original callable with the same function signature.
+##
+## Due to interface conflicts between 'Callable' and 'Object',
+## it is not possible to stub the 'call' and 'call_deferred' methods.
+##
+## The Callable interface and the Object class have overlapping method signatures,
+## which causes conflicts when attempting to stub these methods.
+## As a result, you cannot create stubs for 'call' and 'call_deferred' methods.
+
+class_name CallableDoubler
+
+
+const doubler_script :Script = preload("res://addons/gdUnit4/src/doubler/CallableDoubler.gd")
+
+var _cb: Callable
+
+
+func _init(cb: Callable) -> void:
+ assert(cb!=null, "Invalid argument <cb> must not be null")
+ _cb = cb
+
+## --- helpers -----------------------------------------------------------------------------------------------------------------------------
+static func map_func_name(method_info: Dictionary) -> String:
+ return method_info["name"]
+
+
+## We do not want to double all functions based on Object for this class
+## Is used on SpyBuilder to excluding functions to be doubled for Callable
+static func excluded_functions() -> PackedStringArray:
+ return ClassDB.class_get_method_list("Object")\
+ .map(CallableDoubler.map_func_name)\
+ .filter(func (name: String) -> bool:
+ return !CallableDoubler.callable_functions().has(name))
+
+
+static func non_callable_functions(name: String) -> bool:
+ return ![
+ # we allow "_init", is need to construct it,
+ "excluded_functions",
+ "non_callable_functions",
+ "callable_functions",
+ "map_func_name"
+ ].has(name)
+
+
+## Returns the list of supported Callable functions
+static func callable_functions() -> PackedStringArray:
+ var supported_functions :Array = doubler_script.get_script_method_list()\
+ .map(CallableDoubler.map_func_name)\
+ .filter(CallableDoubler.non_callable_functions)
+ # We manually add these functions that we cannot/may not overwrite in this class
+ supported_functions.append_array(["call_deferred", "callv"])
+ return supported_functions
+
+
+## -----------------------------------------------------------------------------------------------------------------------------------------
+## Callable functions stubing
+## -----------------------------------------------------------------------------------------------------------------------------------------
+
+func bind(...varargs: Array) -> Callable:
+ _cb = _cb.bindv(varargs)
+ return _cb
+
+
+func bindv(caller_args: Array) -> Callable:
+ _cb = _cb.bindv(caller_args)
+ return _cb
+
+
+@warning_ignore("native_method_override")
+func call(...varargs: Array) -> Variant:
+ return _cb.callv(varargs)
+
+
+# Is not supported, see class description
+#func call_deferred(...varargs: Array) -> void:
+# return _cb.call_deferred(varargs)
+
+
+# Is not supported, see class description
+#func callv(arguments: Array) -> Variant:
+# return _cb.callv(arguments)
+
+
+func get_bound_arguments() -> Array:
+ return _cb.get_bound_arguments()
+
+
+func get_bound_arguments_count() -> int:
+ return _cb.get_bound_arguments_count()
+
+
+func get_method() -> StringName:
+ return _cb.get_method()
+
+
+func get_object() -> Object:
+ return _cb.get_object()
+
+
+func get_object_id() -> int:
+ return _cb.get_object_id()
+
+
+func hash() -> int:
+ return _cb.hash()
+
+
+func is_custom() -> bool:
+ return _cb.is_custom()
+
+
+func is_null() -> bool:
+ return _cb.is_null()
+
+
+func is_standard() -> bool:
+ return _cb.is_standard()
+
+
+func is_valid() -> bool:
+ return _cb.is_valid()
+
+
+func rpc(...varargs: Array) -> void:
+ match varargs.size():
+ 0: _cb.rpc()
+ 1: _cb.rpc(varargs[0])
+ 2: _cb.rpc(varargs[0], varargs[1])
+ 3: _cb.rpc(varargs[0], varargs[1], varargs[2])
+ 4: _cb.rpc(varargs[0], varargs[1], varargs[2], varargs[3], varargs[4])
+ 5: _cb.rpc(varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5])
+ 6: _cb.rpc(varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6])
+ 7: _cb.rpc(varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6], varargs[7])
+ 8: _cb.rpc(varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6], varargs[7], varargs[8])
+ 9: _cb.rpc(varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6], varargs[7], varargs[8], varargs[9])
+
+
+@warning_ignore("untyped_declaration")
+func rpc_id(peer_id: int, ...varargs: Array) -> void:
+ match varargs.size():
+ 0: _cb.rpc_id(peer_id )
+ 1: _cb.rpc_id(peer_id, varargs[0])
+ 2: _cb.rpc_id(peer_id, varargs[0], varargs[1])
+ 3: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2])
+ 4: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2], varargs[3], varargs[4])
+ 5: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5])
+ 6: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6])
+ 7: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6], varargs[7])
+ 8: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6], varargs[7], varargs[8])
+ 9: _cb.rpc_id(peer_id, varargs[0], varargs[1], varargs[2], varargs[3], varargs[4], varargs[5], varargs[6], varargs[7], varargs[8], varargs[9])
+
+func unbind(argcount: int) -> Callable:
+ _cb = _cb.unbind(argcount)
+ return _cb
diff --git a/addons/gdUnit4/src/doubler/CallableDoubler.gd.uid b/addons/gdUnit4/src/doubler/CallableDoubler.gd.uid
new file mode 100644
index 0000000..048f196
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/CallableDoubler.gd.uid
@@ -0,0 +1 @@
+uid://dhp64cdhym7sd
diff --git a/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd b/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd
new file mode 100644
index 0000000..d9459dc
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd
@@ -0,0 +1,4 @@
+@abstract class_name GdFunctionDoubler
+extends RefCounted
+
+@abstract func double(func_descriptor: GdFunctionDescriptor) -> PackedStringArray
diff --git a/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd.uid b/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd.uid
new file mode 100644
index 0000000..ebaa0a1
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdFunctionDoubler.gd.uid
@@ -0,0 +1 @@
+uid://bydl05g1nfonb
diff --git a/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd b/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd
new file mode 100644
index 0000000..31aa06b
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd
@@ -0,0 +1,119 @@
+# A class doubler used to mock and spy checked implementations
+class_name GdUnitClassDoubler
+extends RefCounted
+
+const DOUBLER_INSTANCE_ID_PREFIX := "gdunit_doubler_instance_id_"
+const EXCLUDE_VIRTUAL_FUNCTIONS = [
+ # we have to exclude notifications because NOTIFICATION_PREDELETE is try
+ # to delete already freed spy/mock resources and will result in a conflict
+ "_notification",
+ "notification",
+ # https://github.com/godotengine/godot/issues/67461
+ "get_name",
+ "get_path",
+ "duplicate",
+ ]
+# define functions to be exclude when spy or mock checked a scene
+const EXLCUDE_SCENE_FUNCTIONS = [
+ # needs to exclude get/set script functions otherwise it endsup in recursive endless loop
+ "set_script",
+ "get_script",
+ # needs to exclude otherwise verify fails checked collection arguments checked calling to string
+ "_to_string",
+]
+const EXCLUDE_FUNCTIONS = ["new", "free", "get_instance_id", "get_tree"]
+
+
+static func check_leaked_instances() -> void:
+ ## we check that all registered spy/mock instances are removed from the engine meta data
+ for key in Engine.get_meta_list():
+ if key.begins_with(DOUBLER_INSTANCE_ID_PREFIX):
+ var instance: Variant = Engine.get_meta(key)
+ push_error("GdUnit internal error: an spy/mock instance '%s', class:'%s' is not removed from the engine and will lead in a leaked instance!" % [instance, instance.__SOURCE_CLASS])
+ await (Engine.get_main_loop() as SceneTree).process_frame
+
+
+# loads the doubler template
+# class_info = { "class_name": <>, "class_path" : <>}
+static func load_template(template: String, class_info: Dictionary) -> PackedStringArray:
+ var clazz_name: String = class_info.get("class_name")
+ var source_code := template\
+ .replace("${source_class}", clazz_name)\
+ # Replace template class_name DoubledClass<class> with source class name
+ .replace("SourceClassName", clazz_name.replace(".", "_"))
+ var lines := GdScriptParser.to_unix_format(source_code).split("\n")
+ @warning_ignore("return_value_discarded")
+ lines.insert(1, extends_clazz(class_info))
+ lines.insert(0, "@warning_ignore_start('unsafe_call_argument', 'shadowed_variable', 'untyped_declaration', 'native_method_override', 'int_as_enum_without_cast')")
+ return lines
+
+
+static func extends_clazz(class_info: Dictionary) -> String:
+ var clazz_name: String = class_info.get("class_name")
+ var clazz_path: PackedStringArray = class_info.get("class_path", [])
+ # is inner class?
+ if clazz_path.size() > 1:
+ return "extends %s" % clazz_name
+ if clazz_path.size() == 1 and clazz_path[0].ends_with(".gd"):
+ return "extends '%s'" % clazz_path[0]
+ return "extends %s" % clazz_name
+
+
+# double all functions of given instance
+static func double_functions(instance: Object, clazz_name: String, clazz_path: PackedStringArray, func_doubler: GdFunctionDoubler, exclude_functions: Array) -> PackedStringArray:
+ var doubled_source := PackedStringArray()
+ var parser := GdScriptParser.new()
+ var exclude_override_functions := EXCLUDE_VIRTUAL_FUNCTIONS + EXCLUDE_FUNCTIONS + exclude_functions
+ var functions := Array()
+
+ # double script functions
+ if not ClassDB.class_exists(clazz_name):
+ var result := parser.parse(clazz_name, clazz_path)
+ if result.is_error():
+ push_error(result.error_message())
+ return PackedStringArray()
+ var class_descriptor: GdClassDescriptor = result.value()
+ for func_descriptor in class_descriptor.functions():
+ if instance != null and not instance.has_method(func_descriptor.name()):
+ #prints("no virtual func implemented",clazz_name, func_descriptor.name() )
+ continue
+ if functions.has(func_descriptor.name()) or exclude_override_functions.has(func_descriptor.name()):
+ continue
+ doubled_source += func_doubler.double(func_descriptor)
+ functions.append(func_descriptor.name())
+
+ # double regular class functions
+ var clazz_functions := GdObjects.extract_class_functions(clazz_name, clazz_path)
+ for method: Dictionary in clazz_functions:
+ var func_descriptor := GdFunctionDescriptor.extract_from(method)
+ # exclude private core functions
+ if func_descriptor.is_private():
+ continue
+ if functions.has(func_descriptor.name()) or exclude_override_functions.has(func_descriptor.name()):
+ continue
+ # GD-110: Hotfix do not double invalid engine functions
+ if is_invalid_method_descriptior(method):
+ #prints("'%s': invalid method descriptor found! %s" % [clazz_name, method])
+ continue
+ # do not double on not implemented virtual functions
+ if instance != null and not instance.has_method(func_descriptor.name()):
+ #prints("no virtual func implemented",clazz_name, func_descriptor.name() )
+ continue
+ functions.append(func_descriptor.name())
+ doubled_source.append_array(func_doubler.double(func_descriptor))
+ return doubled_source
+
+
+# GD-110
+static func is_invalid_method_descriptior(method: Dictionary) -> bool:
+ var return_info: Dictionary = method["return"]
+ var type: int = return_info["type"]
+ var usage: int = return_info["usage"]
+ var clazz_name: String = return_info["class_name"]
+ # is method returning a type int with a given 'class_name' we have an enum
+ # and the PROPERTY_USAGE_CLASS_IS_ENUM must be set
+ if type == TYPE_INT and not clazz_name.is_empty() and not (usage & PROPERTY_USAGE_CLASS_IS_ENUM):
+ return true
+ if clazz_name == "Variant.Type":
+ return true
+ return false
diff --git a/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd.uid b/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd.uid
new file mode 100644
index 0000000..05ce336
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitClassDoubler.gd.uid
@@ -0,0 +1 @@
+uid://b3nwjwttqye8a
diff --git a/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd b/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd
new file mode 100644
index 0000000..a1a75f1
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd
@@ -0,0 +1,336 @@
+class_name GdUnitFunctionDoublerBuilder
+extends RefCounted
+
+const TYPE_VOID = GdObjects.TYPE_VOID
+const TYPE_VARIANT = GdObjects.TYPE_VARIANT
+const TYPE_VARARG = GdObjects.TYPE_VARARG
+const TYPE_FUNC = GdObjects.TYPE_FUNC
+const TYPE_FUZZER = GdObjects.TYPE_FUZZER
+const TYPE_ENUM = GdObjects.TYPE_ENUM
+
+const DEFAULT_TYPED_RETURN_VALUES := {
+ TYPE_NIL: "null",
+ TYPE_BOOL: "false",
+ TYPE_INT: "0",
+ TYPE_FLOAT: "0.0",
+ TYPE_STRING: "\"\"",
+ TYPE_STRING_NAME: "&\"\"",
+ TYPE_VECTOR2: "Vector2.ZERO",
+ TYPE_VECTOR2I: "Vector2i.ZERO",
+ TYPE_RECT2: "Rect2()",
+ TYPE_RECT2I: "Rect2i()",
+ TYPE_VECTOR3: "Vector3.ZERO",
+ TYPE_VECTOR3I: "Vector3i.ZERO",
+ TYPE_VECTOR4: "Vector4.ZERO",
+ TYPE_VECTOR4I: "Vector4i.ZERO",
+ TYPE_TRANSFORM2D: "Transform2D()",
+ TYPE_PLANE: "Plane()",
+ TYPE_QUATERNION: "Quaternion()",
+ TYPE_AABB: "AABB()",
+ TYPE_BASIS: "Basis()",
+ TYPE_TRANSFORM3D: "Transform3D()",
+ TYPE_PROJECTION: "Projection()",
+ TYPE_COLOR: "Color()",
+ TYPE_NODE_PATH: "NodePath()",
+ TYPE_RID: "RID()",
+ TYPE_OBJECT: "null",
+ TYPE_CALLABLE: "Callable()",
+ TYPE_SIGNAL: "Signal()",
+ TYPE_DICTIONARY: "Dictionary()",
+ TYPE_ARRAY: "Array()",
+ TYPE_PACKED_BYTE_ARRAY: "PackedByteArray()",
+ TYPE_PACKED_INT32_ARRAY: "PackedInt32Array()",
+ TYPE_PACKED_INT64_ARRAY: "PackedInt64Array()",
+ TYPE_PACKED_FLOAT32_ARRAY: "PackedFloat32Array()",
+ TYPE_PACKED_FLOAT64_ARRAY: "PackedFloat64Array()",
+ TYPE_PACKED_STRING_ARRAY: "PackedStringArray()",
+ TYPE_PACKED_VECTOR2_ARRAY: "PackedVector2Array()",
+ TYPE_PACKED_VECTOR3_ARRAY: "PackedVector3Array()",
+ TYPE_PACKED_VECTOR4_ARRAY: "PackedVector4Array()",
+ TYPE_PACKED_COLOR_ARRAY: "PackedColorArray()",
+ GdObjects.TYPE_VARIANT: "null",
+ GdObjects.TYPE_ENUM: "0"
+}
+
+
+# @GlobalScript enums
+# needs to manually map because of https://github.com/godotengine/godot/issues/73835
+const DEFAULT_ENUM_RETURN_VALUES = {
+ "Side" : "SIDE_LEFT",
+ "Corner" : "CORNER_TOP_LEFT",
+ "Orientation" : "HORIZONTAL",
+ "ClockDirection" : "CLOCKWISE",
+ "HorizontalAlignment" : "HORIZONTAL_ALIGNMENT_LEFT",
+ "VerticalAlignment" : "VERTICAL_ALIGNMENT_TOP",
+ "InlineAlignment" : "INLINE_ALIGNMENT_TOP_TO",
+ "EulerOrder" : "EULER_ORDER_XYZ",
+ "Key" : "KEY_NONE",
+ "KeyModifierMask" : "KEY_CODE_MASK",
+ "MouseButton" : "MOUSE_BUTTON_NONE",
+ "MouseButtonMask" : "MOUSE_BUTTON_MASK_LEFT",
+ "JoyButton" : "JOY_BUTTON_INVALID",
+ "JoyAxis" : "JOY_AXIS_INVALID",
+ "MIDIMessage" : "MIDI_MESSAGE_NONE",
+ "Error" : "OK",
+ "PropertyHint" : "PROPERTY_HINT_NONE",
+ "Variant.Type" : "TYPE_NIL",
+ "Vector2.Axis" : "Vector2.AXIS_X",
+ "Vector2i.Axis" : "Vector2i.AXIS_X",
+ "Vector3.Axis" : "Vector3.AXIS_X",
+ "Vector3i.Axis" : "Vector3i.AXIS_X",
+ "Vector4.Axis" : "Vector4.AXIS_X",
+ "Vector4i.Axis" : "Vector4i.AXIS_X",
+}
+
+
+static var def_constructor := """
+ func _init({constructor_args}) -> void:
+ __init_doubler()
+ super({args})
+ """.dedent()
+
+
+static var def_verify_block := """
+ # verify block
+ var __verifier := __get_verifier()
+ if __verifier != null:
+ if __verifier.is_verify_interactions():
+ __verifier.verify_interactions("{func_name}", __args)
+ {default_return}
+ else:
+ __verifier.save_function_interaction("{func_name}", __args)
+ """.dedent().indent("\t").trim_suffix("\n")
+
+
+static var def_prepare_block := """
+ if __is_prepare_return_value():
+ __save_function_return_value("{func_name}", __args)
+ {default_return}
+ """.dedent().indent("\t").trim_suffix("\n")
+
+
+static var def_void_prepare_block := """
+ if __is_prepare_return_value():
+ push_error("Mocking functions with return type void is not allowed!")
+ return
+ """.dedent().indent("\t").trim_suffix("\n")
+
+
+static var def_mock_return := """
+ if __is_do_not_call_real_func("{func_name}", __args):
+ return __return_mock_value("{func_name}", __args, {default_return})
+ """.dedent().indent("\t").trim_suffix("\n")
+
+
+static var def_void_mock_return := """
+ if __is_do_not_call_real_func("{func_name}", __args):
+ return
+ """.dedent().indent("\t").trim_suffix("\n")
+
+
+var fd: GdFunctionDescriptor
+var func_args: Array
+var default_return: String
+var verify_block: String = ""
+var prepare_block: String = ""
+var mock_return: String = ""
+
+
+func _init(descriptor: GdFunctionDescriptor) -> void:
+ # verify all default types are covered
+ for type_key in TYPE_MAX:
+ if not DEFAULT_TYPED_RETURN_VALUES.has(type_key):
+ push_error("missing default definitions! Expexting %d bud is %d" % [DEFAULT_TYPED_RETURN_VALUES.size(), TYPE_MAX])
+ prints("missing default definition for type", type_key)
+ assert(DEFAULT_TYPED_RETURN_VALUES.has(type_key), "Missing Type default definition!")
+
+ fd = descriptor
+ func_args = argument_names()
+ default_return = default_return_value()
+
+
+func build_func_signature() -> String:
+ var return_type := ":" if fd._return_type == TYPE_VARIANT else " -> %s:" % fd.return_type_as_string()
+ return "{static}func {func_name}({args}){return_type}".format({
+ "static" : "static " if fd.is_static() else "",
+ "func_name": fd.name(),
+ "args": arguments_full_quilified(),
+ "return_type": return_type
+ })
+
+
+func arguments_full_quilified() -> String:
+ var collect := PackedStringArray()
+ for arg in fd.args():
+ var name := argument_name(arg)
+ if arg.has_default():
+ var signature := "{argument_name}{arg_typed}={arg_value}".format({
+ "argument_name" : name,
+ "arg_typed" : ":"+GdObjects.type_as_string(arg.type()) if arg.type() == GdObjects.TYPE_VARIANT else "",
+ "arg_value" : arg.value_as_string()
+ })
+ collect.push_back(signature)
+ else:
+ collect.push_back(name)
+ if fd.is_vararg():
+ var arg_descriptor := fd.varargs()[0]
+ collect.push_back("...%s_: Array" % arg_descriptor.name())
+ return ", ".join(collect)
+
+
+func argument_name(arg: GdFunctionArgument) -> String:
+ return arg.name() + "_"
+
+
+func argument_names() -> PackedStringArray:
+ return fd.args().map(argument_name)
+
+
+func argument_default(arg :GdFunctionArgument) -> String:
+ return (arg.value_as_string()
+ if arg.has_default()
+ else DEFAULT_TYPED_RETURN_VALUES.get(arg.type(), "null"))
+
+
+func build_constructor_arguments() -> String:
+ var arguments := PackedStringArray()
+ for arg in fd.args():
+ var default_value := argument_default(arg)
+ var arg_signature := "{name}:{type}={default}".format({
+ "name" : argument_name(arg),
+ "type" : "Variant" if default_value == "null" else "",
+ "default" : default_value
+ })
+ arguments.append(arg_signature)
+ if fd.is_vararg():
+ arguments.append("...varargs: Array")
+ return ", ".join(arguments)
+
+
+func build_arguments() -> String:
+ return "\tvar __args := [{args}]{varargs}".format({
+ "args" : ", ".join(func_args),
+ "varargs" : " + varargs_" if fd.is_vararg() else ""
+ })
+
+
+func build_super_calls() -> String:
+ if !fd.is_vararg():
+ return 'super(%s)\n' % ", ".join(func_args)
+
+ var match_block := "match varargs_.size():\n"
+ for index in range(0, 11):
+ match_block += '{index}: super({args})\n'.format({
+ "index" : index,
+ "args" : ", ".join(func_args + build_vararg_list(index))
+ }).indent("\t")
+ match_block += '_: push_error("To many varradic arguments.")\n'.indent("\t")
+ match_block += "return\n" if is_void_func() else "return %s\n" % default_return
+ return match_block
+
+
+func build_vararg_list(count: int) -> Array:
+ var arg_list := []
+ for index in count:
+ arg_list.append("varargs_[%d]" % index)
+ return arg_list
+
+
+func default_return_value() -> String:
+ var return_type: Variant = fd.return_type()
+ if return_type == GdObjects.TYPE_ENUM:
+ var enum_class := fd._return_class
+ if DEFAULT_ENUM_RETURN_VALUES.has(enum_class):
+ return DEFAULT_ENUM_RETURN_VALUES.get(fd._return_class, "0")
+
+ var enum_path := enum_class.split(".")
+ if enum_path.size() >= 2:
+ var keys := ClassDB.class_get_enum_constants(enum_path[0], enum_path[1])
+ if not keys.is_empty():
+ return "%s.%s" % [enum_path[0], keys[0]]
+ var enum_value: Variant = get_enum_default(enum_class)
+ if enum_value != null:
+ return str(enum_value)
+ # we need fallback for @GlobalScript enums,
+ return DEFAULT_ENUM_RETURN_VALUES.get(fd._return_class, "0")
+ return DEFAULT_TYPED_RETURN_VALUES.get(return_type, "invalid")
+
+
+# Determine the enum default by reflection
+func get_enum_default(value: String) -> Variant:
+ var script := GDScript.new()
+ script.source_code = """
+ extends RefCounted
+
+ static func get_enum_default() -> Variant:
+ return %s.values()[0]
+
+ """.dedent() % value
+ var err := script.reload()
+ if err != OK:
+ push_error("Cant get enum values form '%s', %s" % [value, error_string(err)])
+ return 0
+ @warning_ignore("unsafe_method_access")
+ return script.new().call("get_enum_default")
+
+
+func is_void_func() -> bool:
+ return fd.return_type() == TYPE_NIL or fd.return_type() == TYPE_VOID
+
+
+func with_verify_block() -> GdUnitFunctionDoublerBuilder:
+ verify_block = def_verify_block.format({
+ "func_name" : fd.name(),
+ "default_return" : "return" if is_void_func() else "return " + default_return
+ })
+ return self
+
+
+func with_prepare_block() -> GdUnitFunctionDoublerBuilder:
+ if fd.return_type() == TYPE_NIL or fd.return_type() == GdObjects.TYPE_VOID:
+ prepare_block = def_void_prepare_block
+ return self
+
+ prepare_block = def_prepare_block.format({
+ "func_name" : fd.name(),
+ "default_return" : "return" if is_void_func() else "return " + default_return
+ })
+ return self
+
+
+func with_mocked_return_value() -> GdUnitFunctionDoublerBuilder:
+ if is_void_func():
+ mock_return = def_void_mock_return.format({
+ "func_name" : fd.name(),
+ })
+ else:
+ mock_return = def_mock_return.format({
+ "func_name" : fd.name(),
+ "default_return" : '"no_arg"' if is_void_func() else default_return
+ })
+ return self
+
+
+func build() -> PackedStringArray:
+ if fd.name() == "_init":
+ return [def_constructor.format({
+ "constructor_args" : build_constructor_arguments(),
+ "args" : ", ".join(func_args)
+ })]
+
+ var func_body: PackedStringArray = []
+ func_body.append(build_func_signature())
+ func_body.append(build_arguments())
+ if not prepare_block.is_empty():
+ func_body.append(prepare_block)
+ func_body.append(verify_block)
+ if not mock_return.is_empty():
+ func_body.append(mock_return)
+ func_body.append("")
+ var super_calls := build_super_calls()
+ if not is_void_func():
+ super_calls = super_calls.replace("super(", "return super(" )
+ if fd.is_coroutine():
+ super_calls = super_calls.replace("super(", "await super(" )
+ func_body.append(super_calls.indent("\t"))
+ return func_body
diff --git a/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd.uid b/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd.uid
new file mode 100644
index 0000000..ca2ad79
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitFunctionDoublerBuilder.gd.uid
@@ -0,0 +1 @@
+uid://bx657fdodkwc6
diff --git a/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd b/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd
new file mode 100644
index 0000000..d735bc5
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd
@@ -0,0 +1,10 @@
+class_name GdUnitMockFunctionDoubler
+extends GdFunctionDoubler
+
+
+func double(func_descriptor: GdFunctionDescriptor) -> PackedStringArray:
+ return GdUnitFunctionDoublerBuilder.new(func_descriptor)\
+ .with_prepare_block()\
+ .with_verify_block()\
+ .with_mocked_return_value()\
+ .build()
diff --git a/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd.uid b/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd.uid
new file mode 100644
index 0000000..4117f4e
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitMockFunctionDoubler.gd.uid
@@ -0,0 +1 @@
+uid://teng0fasbhen
diff --git a/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd b/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd
new file mode 100644
index 0000000..789eb32
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd
@@ -0,0 +1,53 @@
+class_name GdUnitObjectInteractions
+extends RefCounted
+
+
+static func verify(interaction_object: Object, interactions_times: int) -> Variant:
+ if not _is_mock_or_spy(interaction_object):
+ return interaction_object
+
+ _get_verifier(interaction_object).do_verify_interactions(interactions_times)
+ return interaction_object
+
+
+static func verify_no_interactions(interaction_object: Object) -> GdUnitAssert:
+ var assert_tool := GdUnitAssertImpl.new("")
+ if not _is_mock_or_spy(interaction_object):
+ return assert_tool.report_success()
+
+ var summary := _get_verifier(interaction_object).verify_no_interactions()
+ if summary.is_empty():
+ return assert_tool.report_success()
+ return assert_tool.report_error(GdAssertMessages.error_no_more_interactions(summary))
+
+
+static func verify_no_more_interactions(interaction_object: Object) -> GdUnitAssert:
+ var assert_tool := GdUnitAssertImpl.new("")
+ if not _is_mock_or_spy(interaction_object):
+ return assert_tool
+
+ var summary := _get_verifier(interaction_object).verify_no_more_interactions()
+ if summary.is_empty():
+ return assert_tool
+ return assert_tool.report_error(GdAssertMessages.error_no_more_interactions(summary))
+
+
+static func reset(interaction_object: Object) -> Object:
+ if not _is_mock_or_spy(interaction_object):
+ return interaction_object
+
+ _get_verifier(interaction_object).reset_interactions()
+ return interaction_object
+
+
+static func _is_mock_or_spy(instance: Object) -> bool:
+ if instance != null and instance.has_method("__get_verifier"):
+ return true
+
+ push_error("Error: The given object '%s' is not a mock or spy instance!" % instance)
+ return false
+
+
+static func _get_verifier(interaction_object: Object) -> GdUnitObjectInteractionsVerifier:
+ @warning_ignore("unsafe_method_access")
+ return interaction_object.__get_verifier()
diff --git a/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd.uid b/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd.uid
new file mode 100644
index 0000000..54ed63f
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd.uid
@@ -0,0 +1 @@
+uid://cf4wbrdcfcsap
diff --git a/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd b/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd
new file mode 100644
index 0000000..36aa978
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd
@@ -0,0 +1,84 @@
+class_name GdUnitObjectInteractionsVerifier
+
+var expected_interactions: int = -1
+var saved_interactions := Dictionary()
+var verified_interactions := Array()
+
+
+func save_function_interaction(func_name: String, args :Array[Variant]) -> void:
+ var function_args := [func_name] + args
+ var matcher := GdUnitArgumentMatchers.to_matcher(function_args, true)
+ for index in saved_interactions.keys().size():
+ var key: Variant = saved_interactions.keys()[index]
+ if matcher.is_match(key):
+ saved_interactions[key] += 1
+ return
+ saved_interactions[function_args] = 1
+
+
+func is_verify_interactions() -> bool:
+ return expected_interactions != -1
+
+
+func do_verify_interactions(interactions_times: int = 1) -> void:
+ expected_interactions = interactions_times
+
+
+func verify_interactions(func_name: String, args: Array[Variant]) -> void:
+ var summary := Dictionary()
+ var total_interactions := 0
+ var function_args := [func_name] + args
+ var matcher := GdUnitArgumentMatchers.to_matcher(function_args, true)
+ for index in saved_interactions.keys().size():
+ var key: Variant = saved_interactions.keys()[index]
+ if matcher.is_match(key):
+ var interactions: int = saved_interactions.get(key, 0)
+ total_interactions += interactions
+ summary[key] = interactions
+ # add as verified
+ verified_interactions.append(key)
+
+ var assert_tool := GdUnitAssertImpl.new("")
+ if total_interactions != expected_interactions:
+ var __expected_summary := {function_args : expected_interactions}
+ var error_message: String
+ # if no interactions macht collect not verified interactions for failure report
+ if summary.is_empty():
+ var __current_summary := verify_no_more_interactions()
+ error_message = GdAssertMessages.error_validate_interactions(__current_summary, __expected_summary)
+ else:
+ error_message = GdAssertMessages.error_validate_interactions(summary, __expected_summary)
+ @warning_ignore("return_value_discarded")
+ assert_tool.report_error(error_message)
+ else:
+ @warning_ignore("return_value_discarded")
+ assert_tool.report_success()
+ expected_interactions = -1
+
+
+func verify_no_interactions() -> Dictionary:
+ var summary := Dictionary()
+ if not saved_interactions.is_empty():
+ for index in saved_interactions.keys().size():
+ var func_call: Variant = saved_interactions.keys()[index]
+ summary[func_call] = saved_interactions[func_call]
+ return summary
+
+
+func verify_no_more_interactions() -> Dictionary:
+ var summary := Dictionary()
+ var called_functions: Array[Variant] = saved_interactions.keys()
+ if called_functions != verified_interactions:
+ # collect the not verified functions
+ var called_but_not_verified := called_functions.duplicate()
+ for index in verified_interactions.size():
+ called_but_not_verified.erase(verified_interactions[index])
+
+ for index in called_but_not_verified.size():
+ var not_verified: Variant = called_but_not_verified[index]
+ summary[not_verified] = saved_interactions[not_verified]
+ return summary
+
+
+func reset_interactions() -> void:
+ saved_interactions.clear()
diff --git a/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd.uid b/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd.uid
new file mode 100644
index 0000000..9b42fe6
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitObjectInteractionsVerifier.gd.uid
@@ -0,0 +1 @@
+uid://djphb0prfq8if
diff --git a/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd b/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd
new file mode 100644
index 0000000..091241d
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd
@@ -0,0 +1,8 @@
+class_name GdUnitSpyFunctionDoubler
+extends GdFunctionDoubler
+
+
+func double(func_descriptor: GdFunctionDescriptor) -> PackedStringArray:
+ return GdUnitFunctionDoublerBuilder.new(func_descriptor)\
+ .with_verify_block()\
+ .build()
diff --git a/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd.uid b/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd.uid
new file mode 100644
index 0000000..322f6c9
--- /dev/null
+++ b/addons/gdUnit4/src/doubler/GdUnitSpyFunctionDoubler.gd.uid
@@ -0,0 +1 @@
+uid://3rjtjcj64vpt
diff --git a/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd b/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd
new file mode 100644
index 0000000..124d3d4
--- /dev/null
+++ b/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd
@@ -0,0 +1,73 @@
+# This class defines a value extractor by given function name and args
+class_name GdUnitFuncValueExtractor
+extends GdUnitValueExtractor
+
+var _func_names :PackedStringArray
+var _args :Array
+
+func _init(func_name :String, p_args :Array) -> void:
+ _func_names = func_name.split(".")
+ _args = p_args
+
+
+func func_names() -> PackedStringArray:
+ return _func_names
+
+
+func args() -> Array:
+ return _args
+
+
+# Extracts a value by given `func_name` and `args`,
+# Allows to use a chained list of functions setarated ba a dot.
+# e.g. "func_a.func_b.name"
+# do calls instance.func_a().func_b().name() and returns finally the name
+# If a function returns an array, all elements will by collected in a array
+# e.g. "get_children.get_name" checked a node
+# do calls node.get_children() for all childs get_name() and returns all names in an array
+#
+# if the value not a Object or not accesible be `func_name` the value is converted to `"n.a."`
+# expecing null values
+func extract_value(value: Variant) -> Variant:
+ if value == null:
+ return null
+ for func_name in func_names():
+ if GdArrayTools.is_array_type(value):
+ var values := Array()
+ @warning_ignore("unsafe_cast")
+ for element: Variant in (value as Array):
+ values.append(_call_func(element, func_name))
+ value = values
+ else:
+ value = _call_func(value, func_name)
+ var type := typeof(value)
+ if type == TYPE_STRING_NAME:
+ return str(value)
+ if type == TYPE_STRING and value == "n.a.":
+ return value
+ return value
+
+
+func _call_func(value :Variant, func_name :String) -> Variant:
+ # for array types we need to call explicit by function name, using funcref is only supported for Objects
+ # TODO extend to all array functions
+ if GdArrayTools.is_array_type(value) and func_name == "empty":
+ @warning_ignore("unsafe_cast")
+ return (value as Array).is_empty()
+
+ if is_instance_valid(value):
+ # extract from function
+ var obj_value: Object = value
+ if obj_value.has_method(func_name):
+ var extract := Callable(obj_value, func_name)
+ if extract.is_valid():
+ return obj_value.call(func_name) if args().is_empty() else obj_value.callv(func_name, args())
+ else:
+ # if no function exists than try to extract form parmeters
+ var parameter: Variant = obj_value.get(func_name)
+ if parameter != null:
+ return parameter
+ # nothing found than return 'n.a.'
+ if GdUnitSettings.is_verbose_assert_warnings():
+ push_warning("Extracting value from element '%s' by func '%s' failed! Converting to \"n.a.\"" % [value, func_name])
+ return "n.a."
diff --git a/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd.uid b/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd.uid
new file mode 100644
index 0000000..9c4c194
--- /dev/null
+++ b/addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd.uid
@@ -0,0 +1 @@
+uid://g7yhter46jh
diff --git a/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd b/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd
new file mode 100644
index 0000000..906ee2c
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd
@@ -0,0 +1,24 @@
+## A fuzzer that generates random boolean values for testing.[br]
+##
+## This is useful for testing code paths that
+## depend on boolean conditions, flags, or toggle states.[br]
+##
+## [b]Usage example:[/b]
+## [codeblock]
+## func test_toggle_feature(fuzzer := BoolFuzzer.new(), _fuzzer_iterations = 100):
+## var enabled := fuzzer.next_value()
+## my_feature.set_enabled(enabled)
+## assert_bool(my_feature.is_enabled()),is_equal(enabled)
+## [/codeblock]
+class_name BoolFuzzer
+extends Fuzzer
+
+
+## Generates a random boolean value.[br]
+##
+## Returns either [code]true[/code] or [code]false[/code] with equal probability.
+## This method is called automatically during fuzz testing iterations.[br]
+##
+## @returns A randomly generated boolean value ([code]true[/code] or [code]false[/code]).
+func next_value() -> bool:
+ return randi() % 2
diff --git a/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd.uid
new file mode 100644
index 0000000..f2a9436
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/BoolFuzzer.gd.uid
@@ -0,0 +1 @@
+uid://c0b4yc7wahhsc
diff --git a/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd b/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd
new file mode 100644
index 0000000..5da1a77
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd
@@ -0,0 +1,37 @@
+## A fuzzer that generates random floating-point values within a specified range.[br]
+##
+## This is particularly useful for testing numerical calculations,
+## physics simulations, shader parameters, or any code that processes floating-point
+## values.[br]
+##
+## [b]Usage example:[/b]
+## [codeblock]
+## func test_calculate_damage(fuzzer := FloatFuzzer.new(0.0, 100.0), _fuzzer_iterations := 500):
+## var damage := fuzzer.next_value()
+## var result = calculate_damage_reduction(damage)
+## assert_float(result).is_between(0.0, damage)
+## [/codeblock]
+## [br]
+## [b]Note:[/b] The range is inclusive on both ends, and values are uniformly distributed.
+class_name FloatFuzzer
+extends Fuzzer
+
+## Minimum value (inclusive) for generated floats.
+var _from: float = 0
+## Maximum value (inclusive) for generated floats.
+var _to: float = 0
+
+func _init(from: float, to: float) -> void:
+ assert(from <= to, "Invalid range!")
+ _from = from
+ _to = to
+
+
+## Generates a random float value within the configured range.[br]
+##
+## Returns a uniformly distributed random float between [member _from] and
+## [member _to] (inclusive). Each call produces a new random value.[br]
+##
+## @returns A random float value within the specified range.
+func next_value() -> float:
+ return randf_range(_from, _to)
diff --git a/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd.uid
new file mode 100644
index 0000000..3c67b8a
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/FloatFuzzer.gd.uid
@@ -0,0 +1 @@
+uid://droy0thepcy
diff --git a/addons/gdUnit4/src/fuzzers/Fuzzer.gd b/addons/gdUnit4/src/fuzzers/Fuzzer.gd
new file mode 100644
index 0000000..e4e597d
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/Fuzzer.gd
@@ -0,0 +1,80 @@
+## Base interface for fuzz testing.[br]
+##
+## Fuzzer is an abstract base class that provides the foundation for creating
+## custom fuzzers used in automated testing. Fuzz testing (fuzzing) is a software
+## testing technique that involves providing invalid, unexpected, or random data
+## as inputs to a program to find bugs and potential security vulnerabilities.
+## [br][br]
+## To use a fuzzer in your test cases, add optional parameters to your test function:
+## [codeblock]
+## func test_foo(fuzzer := Fuzzers.randomInt(), _fuzzer_iterations := 10, _fuzzer_seed := 12345):
+## var value := fuzzer.next_value()
+## # Test logic using the fuzzed value
+## [/codeblock]
+## [br]
+## @tutorial(Fuzzing on Wikipedia): https://en.wikipedia.org/wiki/Fuzzing
+@abstract
+class_name Fuzzer
+extends RefCounted
+
+## Default number of iterations for fuzz testing when not specified.
+const ITERATION_DEFAULT_COUNT := 1000
+## Parameter name for passing the fuzzer instance to test functions.
+const ARGUMENT_FUZZER_INSTANCE := "fuzzer"
+## Parameter name for specifying the number of iterations in test functions.
+const ARGUMENT_ITERATIONS := "fuzzer_iterations"
+## Parameter name for specifying the random seed in test functions.
+const ARGUMENT_SEED := "fuzzer_seed"
+
+## Current iteration index during fuzzing execution.
+var _iteration_index := 0
+## Maximum number of iterations to run for this fuzzer.
+var _iteration_limit := ITERATION_DEFAULT_COUNT
+
+
+## Generates the next fuzz value.[br]
+##
+## This abstract method must be implemented by derived classes to provide
+## the specific fuzzing logic for generating test values.[br]
+##
+## [b]Example implementation:[/b]
+## [codeblock]
+## func next_value() -> int:
+## return randi_range(0, 100)
+## [/codeblock]
+##
+## @returns The next generated fuzz value. The type depends on the specific fuzzer implementation.
+@abstract
+func next_value() -> Variant
+
+
+## Returns the current iteration index.[br]
+##
+## Useful for tracking progress during fuzzing or for debugging purposes
+## when a specific iteration causes a failure.[br]
+##
+## [b]Example:[/b]
+## [codeblock]
+## if fuzzer.iteration_index() % 100 == 0:
+## print("Processed %d iterations" % fuzzer.iteration_index())
+## [/codeblock]
+##
+## @returns The current iteration index, starting from 0.
+func iteration_index() -> int:
+ return _iteration_index
+
+
+## Returns the maximum number of iterations for this fuzzer.[br]
+##
+## This value determines how many times the fuzzer will generate values
+## during a test run. It can be overridden by the [code]fuzzer_iterations[/code]
+## parameter in test functions.[br]
+##
+## [b]Example:[/b]
+## [codeblock]
+## print("Running %d fuzzing iterations" % fuzzer.iteration_limit())
+## [/codeblock]
+##
+## @returns The maximum number of iterations to be executed.
+func iteration_limit() -> int:
+ return _iteration_limit
diff --git a/addons/gdUnit4/src/fuzzers/Fuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/Fuzzer.gd.uid
new file mode 100644
index 0000000..ca5a34d
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/Fuzzer.gd.uid
@@ -0,0 +1 @@
+uid://dnqosea1sx5f4
diff --git a/addons/gdUnit4/src/fuzzers/IntFuzzer.gd b/addons/gdUnit4/src/fuzzers/IntFuzzer.gd
new file mode 100644
index 0000000..4100d51
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/IntFuzzer.gd
@@ -0,0 +1,77 @@
+## A fuzzer that generates random integer values with optional even/odd constraints.[br]
+##
+## It supports three modes: normal (any integer), even-only,
+## and odd-only generation. This is useful for testing array indices, loop counters,
+## enumeration values, or any code that processes integer values.[br]
+##
+## [b]Usage example:[/b]
+## [codeblock]
+## # Test with any integer in range
+## func test_array_access(fuzzer = IntFuzzer.new(0, 99), fuzzer_iterations = 100):
+## var index = fuzzer.next_value()
+## var array = create_array(100)
+## assert(array[index] != null)
+##
+## # Test with only even numbers
+## func test_even_processing(fuzzer := IntFuzzer.new(0, 100, IntFuzzer.EVEN)):
+## var even_num := fuzzer.next_value()
+## assert_int(even_num % 2).is_equal(0)
+## [/codeblock]
+class_name IntFuzzer
+extends Fuzzer
+
+
+## Generates any integer within the range.
+enum {
+ NORMAL, ## Generate any integer within the specified range.
+ EVEN, ## Generate only even integers within the specified range.
+ ODD ## Generate only odd integers within the specified range.
+}
+
+
+## Minimum value (inclusive) for generated integers.
+var _from: int = 0
+## Maximum value (inclusive) for generated integers.
+var _to: int = 0
+## Generation mode: NORMAL, EVEN, or ODD.
+var _mode: int = NORMAL
+
+
+func _init(from: int, to: int, mode: int = NORMAL) -> void:
+ assert(from <= to, "Invalid range!")
+ _from = from
+ _to = to
+ _mode = mode
+
+
+## Generates a random integer value based on the configured mode.[br]
+##
+## Returns a random integer between [member _from] and [member _to] (inclusive).[br]
+## The value will be constrained according to the [member _mode]:[br]
+## - [constant NORMAL]: Any integer in the range[br]
+## - [constant EVEN]: Only even integers[br]
+## - [constant ODD]: Only odd integers[br]
+##
+## [b]Example:[/b]
+## [codeblock]
+## var normal_fuzzer = IntFuzzer.new(1, 10, IntFuzzer.NORMAL)
+## var even_fuzzer = IntFuzzer.new(1, 10, IntFuzzer.EVEN)
+## var odd_fuzzer = IntFuzzer.new(1, 10, IntFuzzer.ODD)
+##
+## print(normal_fuzzer.next_value()) # Could be any: 1, 2, 3, ..., 10
+## print(even_fuzzer.next_value()) # Only even: 2, 4, 6, 8, 10
+## print(odd_fuzzer.next_value()) # Only odd: 1, 3, 5, 7, 9
+## [/codeblock]
+##
+## @returns A random integer value within the specified range and mode
+func next_value() -> int:
+ var value := randi_range(_from, _to)
+ match _mode:
+ NORMAL:
+ return value
+ EVEN:
+ return int((value / 2.0) * 2)
+ ODD:
+ return int((value / 2.0) * 2 + 1)
+ _:
+ return value
diff --git a/addons/gdUnit4/src/fuzzers/IntFuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/IntFuzzer.gd.uid
new file mode 100644
index 0000000..e5f1d95
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/IntFuzzer.gd.uid
@@ -0,0 +1 @@
+uid://bt16fv8esh0f8
diff --git a/addons/gdUnit4/src/fuzzers/StringFuzzer.gd b/addons/gdUnit4/src/fuzzers/StringFuzzer.gd
new file mode 100644
index 0000000..277b4ac
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/StringFuzzer.gd
@@ -0,0 +1,112 @@
+## A fuzzer that generates random strings with configurable length and character sets.[br]
+##
+## It supports custom character sets defined by patterns or ranges,
+## making it ideal for testing input validation, text processing, parsers, or any
+## code that handles string data.[br]
+##
+## The fuzzer uses a pattern syntax to define allowed characters:[br]
+## - Single characters: [code]abc[/code] allows 'a', 'b', 'c'[br]
+## - Ranges: [code]a-z[/code] allows lowercase letters[br]
+## - Special patterns: [code]\\w[/code] (word chars), [code]\\p{L}[/code] (letters), [code]\\p{N}[/code] (numbers)[br]
+##
+## [b]Usage example:[/b]
+## [codeblock]
+## # Test with alphanumeric strings
+## func test_username(fuzzer := StringFuzzer.new(3, 20, "a-zA-Z0-9"), _fuzzer_iterations := 100):
+## var username _= fuzzer.next_value()
+## assert_bool(validate_username(username)).is_true()
+##
+## # Test with special characters
+## func test_password(fuzzer := StringFuzzer.new(8, 32, "a-zA-Z0-9!@#$%"), _fuzzer_iterations := 100) -> void:
+## var password := fuzzer.next_value()
+## assert_str(password).has_length(8, Comparator.GREATER_EQUAL).has_length(32, Comparator.LESS_EQUAL)
+## [/codeblock]
+class_name StringFuzzer
+extends Fuzzer
+
+## Default character set pattern including word characters, letters, numbers, and common symbols.[br]
+## Includes: word characters (\\w), Unicode letters (\\p{L}), Unicode numbers (\\p{N}),
+## and the characters: +, -, _, '
+const DEFAULT_CHARSET = "\\w\\p{L}\\p{N}+-_'"
+
+## Minimum length for generated strings (inclusive).
+var _min_length: int
+## Maximum length for generated strings (inclusive).
+var _max_length: int
+## Array of character codes that can be used in generated strings.
+var _charset: PackedInt32Array
+
+
+func _init(min_length: int, max_length: int, pattern: String = DEFAULT_CHARSET) -> void:
+ _min_length = min_length
+ _max_length = max_length + 1 # +1 for inclusive
+ assert(not null or not pattern.is_empty())
+ assert(_min_length > 0 and _min_length < _max_length)
+ _charset = _extract_charset(pattern)
+
+
+## Generates a random string based on configured parameters.[br]
+##
+## Creates a string with random length between [member _min_length] and
+## [member _max_length], using only characters from the configured charset.
+## Each character is selected randomly and independently.[br]
+##
+## [b]Example:[/b]
+## [codeblock]
+## var fuzzer = StringFuzzer.new(5, 10, "ABC")
+## for i in range(5):
+## var str = fuzzer.next_value()
+## print("Generated: ", str)
+## # Possible outputs: "ABCAB", "BCAABCA", "CCCBAA", etc.
+## assert(str.length() >= 5 and str.length() <= 10)
+## for c in str:
+## assert(c in ["A", "B", "C"])
+## [/codeblock]
+##
+## @returns A random string matching the configured constraints.
+func next_value() -> String:
+ var value := PackedInt32Array()
+ var max_char := len(_charset)
+ var length: int = max(_min_length, randi() % _max_length)
+ for i in length:
+ @warning_ignore("return_value_discarded")
+ value.append(_charset[randi() % max_char])
+ return value.to_byte_array().get_string_from_utf32()
+
+
+static func _extract_charset(pattern: String) -> PackedInt32Array:
+ var reg := RegEx.new()
+ if reg.compile(pattern) != OK:
+ push_error("Invalid pattern to generate Strings! Use e.g '\\w\\p{L}\\p{N}+-_'")
+ return PackedInt32Array()
+
+ var charset := PackedInt32Array()
+ var char_before := -1
+ var index := 0
+ while index < pattern.length():
+ var char_current := pattern.unicode_at(index)
+ # - range token at first or last pos?
+ if char_current == 45 and (index == 0 or index == pattern.length()-1):
+ charset.append(char_current)
+ index += 1
+ continue
+ index += 1
+ # range starts
+ if char_current == 45 and char_before != -1:
+ var char_next := pattern.unicode_at(index)
+ var characters := _build_chars(char_before, char_next)
+ for character in characters:
+ charset.append(character)
+ char_before = -1
+ index += 1
+ continue
+ char_before = char_current
+ charset.append(char_current)
+ return charset
+
+
+static func _build_chars(from: int, to: int) -> PackedInt32Array:
+ var characters := PackedInt32Array()
+ for character in range(from+1, to+1):
+ characters.append(character)
+ return characters
diff --git a/addons/gdUnit4/src/fuzzers/StringFuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/StringFuzzer.gd.uid
new file mode 100644
index 0000000..3fa5555
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/StringFuzzer.gd.uid
@@ -0,0 +1 @@
+uid://b8l1qcoler3pb
diff --git a/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd b/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd
new file mode 100644
index 0000000..70ef31b
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd
@@ -0,0 +1,45 @@
+## A fuzzer that generates random Vector2 values within a specified rectangular range.[br]
+##
+## This is particularly useful for testing 2D physics, movement
+## systems, UI positioning, sprite coordinates, or any code that processes 2D vectors.[br]
+##
+## The fuzzer generates vectors where each component (x, y) is independently randomized
+## within its respective range, creating a uniform distribution over the rectangular area.[br]
+##
+## [b]Usage example:[/b]
+## [codeblock]
+## # Test 2D movement within screen bounds
+## func test_movement(fuzzer := Vector2Fuzzer.new(Vector2.ZERO, Vector2(1920, 1080)), _fuzzer_iterations := 200) -> void:
+## var position := fuzzer.next_value()
+## player.set_position(position)
+##
+## [/codeblock]
+class_name Vector2Fuzzer
+extends Fuzzer
+
+
+## Minimum bounds for the generated vectors (inclusive for both x and y).
+var _from: Vector2
+## Maximum bounds for the generated vectors (inclusive for both x and y).
+var _to: Vector2
+
+
+func _init(from: Vector2, to: Vector2) -> void:
+ assert(from <= to, "Invalid range!")
+ _from = from
+ _to = to
+
+
+## Generates a random Vector2 within the configured rectangular range.[br]
+##
+## Returns a Vector2 where each component is independently randomized:[br]
+## - x: random float between [code]_from.x[/code] and [code]_to.x[/code][br]
+## - y: random float between [code]_from.y[/code] and [code]_to.y[/code][br]
+##
+## The distribution is uniform over the rectangular area defined by the bounds.[br]
+##
+## @returns A random Vector2 within the specified range.
+func next_value() -> Vector2:
+ var x := randf_range(_from.x, _to.x)
+ var y := randf_range(_from.y, _to.y)
+ return Vector2(x, y)
diff --git a/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd.uid
new file mode 100644
index 0000000..c68746c
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/Vector2Fuzzer.gd.uid
@@ -0,0 +1 @@
+uid://dvpctakk4c5u0
diff --git a/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd b/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd
new file mode 100644
index 0000000..b54870d
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd
@@ -0,0 +1,48 @@
+## A fuzzer that generates random Vector3 values within a specified box range.[br]
+##
+## This is particularly useful for testing 3D physics, spatial
+## positioning, camera systems, particle effects, or any code that processes 3D vectors.[br]
+##
+## The fuzzer generates vectors where each component (x, y, z) is independently
+## randomized within its respective range, creating a uniform distribution over the
+## 3D box volume.[br]
+##
+## [b]Usage example:[/b]
+## [codeblock]
+## # Test 3D object placement within world bounds
+## func test_spawn_position(fuzzer := Vector3Fuzzer.new(Vector3(-100, 0, -100), Vector3(100, 50, 100)), _fuzzer_iterations := 300):
+## var position := fuzzer.next_value()
+## var object = spawn_object(position)
+##
+## [/codeblock]
+class_name Vector3Fuzzer
+extends Fuzzer
+
+
+## Minimum bounds for the generated vectors (inclusive for x, y, and z).
+var _from: Vector3
+## Maximum bounds for the generated vectors (inclusive for x, y, and z).
+var _to: Vector3
+
+
+func _init(from: Vector3, to: Vector3) -> void:
+ assert(from <= to, "Invalid range!")
+ _from = from
+ _to = to
+
+
+## Generates a random Vector3 within the configured box range.[br]
+##
+## Returns a Vector3 where each component is independently randomized:[br]
+## - x: random float between [code]_from.x[/code] and [code]_to.x[/code][br]
+## - y: random float between [code]_from.y[/code] and [code]_to.y[/code][br]
+## - z: random float between [code]_from.z[/code] and [code]_to.z[/code][br]
+##
+## The distribution is uniform over the 3D box volume defined by the bounds.[br]
+##
+## @returns A random Vector3 within the specified range.
+func next_value() -> Vector3:
+ var x := randf_range(_from.x, _to.x)
+ var y := randf_range(_from.y, _to.y)
+ var z := randf_range(_from.z, _to.z)
+ return Vector3(x, y, z)
diff --git a/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd.uid b/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd.uid
new file mode 100644
index 0000000..cba3a8f
--- /dev/null
+++ b/addons/gdUnit4/src/fuzzers/Vector3Fuzzer.gd.uid
@@ -0,0 +1 @@
+uid://cde6yudknpbgf
diff --git a/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd b/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd
new file mode 100644
index 0000000..bd50313
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd
@@ -0,0 +1,11 @@
+class_name AnyArgumentMatcher
+extends GdUnitArgumentMatcher
+
+
+@warning_ignore("unused_parameter")
+func is_match(value :Variant) -> bool:
+ return true
+
+
+func _to_string() -> String:
+ return "any()"
diff --git a/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd.uid
new file mode 100644
index 0000000..03ccbea
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/AnyArgumentMatcher.gd.uid
@@ -0,0 +1 @@
+uid://n0fqyyrngu3p
diff --git a/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd b/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd
new file mode 100644
index 0000000..ba34431
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd
@@ -0,0 +1,50 @@
+class_name AnyBuildInTypeArgumentMatcher
+extends GdUnitArgumentMatcher
+
+var _type : PackedInt32Array = []
+
+
+func _init(type :PackedInt32Array) -> void:
+ _type = type
+
+
+func is_match(value :Variant) -> bool:
+ return _type.has(typeof(value))
+
+
+func _to_string() -> String:
+ match _type[0]:
+ TYPE_BOOL: return "any_bool()"
+ TYPE_STRING, TYPE_STRING_NAME: return "any_string()"
+ TYPE_INT: return "any_int()"
+ TYPE_FLOAT: return "any_float()"
+ TYPE_COLOR: return "any_color()"
+ TYPE_VECTOR2: return "any_vector2()" if _type.size() == 1 else "any_vector()"
+ TYPE_VECTOR2I: return "any_vector2i()"
+ TYPE_VECTOR3: return "any_vector3()"
+ TYPE_VECTOR3I: return "any_vector3i()"
+ TYPE_VECTOR4: return "any_vector4()"
+ TYPE_VECTOR4I: return "any_vector4i()"
+ TYPE_RECT2: return "any_rect2()"
+ TYPE_RECT2I: return "any_rect2i()"
+ TYPE_PLANE: return "any_plane()"
+ TYPE_QUATERNION: return "any_quat()"
+ TYPE_AABB: return "any_aabb()"
+ TYPE_BASIS: return "any_basis()"
+ TYPE_TRANSFORM2D: return "any_transform_2d()"
+ TYPE_TRANSFORM3D: return "any_transform_3d()"
+ TYPE_NODE_PATH: return "any_node_path()"
+ TYPE_RID: return "any_rid()"
+ TYPE_OBJECT: return "any_object()"
+ TYPE_DICTIONARY: return "any_dictionary()"
+ TYPE_ARRAY: return "any_array()"
+ TYPE_PACKED_BYTE_ARRAY: return "any_packed_byte_array()"
+ TYPE_PACKED_INT32_ARRAY: return "any_packed_int32_array()"
+ TYPE_PACKED_INT64_ARRAY: return "any_packed_int64_array()"
+ TYPE_PACKED_FLOAT32_ARRAY: return "any_packed_float32_array()"
+ TYPE_PACKED_FLOAT64_ARRAY: return "any_packed_float64_array()"
+ TYPE_PACKED_STRING_ARRAY: return "any_packed_string_array()"
+ TYPE_PACKED_VECTOR2_ARRAY: return "any_packed_vector2_array()"
+ TYPE_PACKED_VECTOR3_ARRAY: return "any_packed_vector3_array()"
+ TYPE_PACKED_COLOR_ARRAY: return "any_packed_color_array()"
+ _: return "any()"
diff --git a/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd.uid
new file mode 100644
index 0000000..68f1c4f
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/AnyBuildInTypeArgumentMatcher.gd.uid
@@ -0,0 +1 @@
+uid://cjg3dte8ifp72
diff --git a/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd b/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd
new file mode 100644
index 0000000..b5e3de3
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd
@@ -0,0 +1,32 @@
+class_name AnyClazzArgumentMatcher
+extends GdUnitArgumentMatcher
+
+var _clazz :Object
+
+
+func _init(clazz :Object) -> void:
+ _clazz = clazz
+
+
+func is_match(value :Variant) -> bool:
+ if typeof(value) != TYPE_OBJECT:
+ return false
+ if is_instance_valid(value) and GdObjects.is_script(_clazz):
+ @warning_ignore("unsafe_cast")
+ return (value as Object).get_script() == _clazz
+ return is_instance_of(value, _clazz)
+
+
+func _to_string() -> String:
+ if (_clazz as Object).is_class("GDScriptNativeClass"):
+ @warning_ignore("unsafe_method_access")
+ var instance :Object = _clazz.new()
+ var clazz_name := instance.get_class()
+ if not instance is RefCounted:
+ instance.free()
+ return "any_class(<"+clazz_name+">)";
+ if _clazz is GDScript:
+ var result := GdObjects.extract_class_name(_clazz)
+ if result.is_success():
+ return "any_class(<"+ result.value() + ">)"
+ return "any_class()"
diff --git a/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd.uid
new file mode 100644
index 0000000..e6d4402
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/AnyClazzArgumentMatcher.gd.uid
@@ -0,0 +1 @@
+uid://b331ucr86yg1o
diff --git a/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd b/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd
new file mode 100644
index 0000000..f779bd7
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd
@@ -0,0 +1,22 @@
+class_name ChainedArgumentMatcher
+extends GdUnitArgumentMatcher
+
+var _matchers :Array
+
+
+func _init(matchers :Array) -> void:
+ _matchers = matchers
+
+
+func is_match(arguments :Variant) -> bool:
+ var arg_array: Array = arguments
+ if arg_array == null or arg_array.size() != _matchers.size():
+ return false
+
+ for index in arg_array.size():
+ var arg: Variant = arg_array[index]
+ var matcher: GdUnitArgumentMatcher = _matchers[index]
+
+ if not matcher.is_match(arg):
+ return false
+ return true
diff --git a/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd.uid
new file mode 100644
index 0000000..364cd27
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/ChainedArgumentMatcher.gd.uid
@@ -0,0 +1 @@
+uid://b2tgtmtmgsl44
diff --git a/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd b/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd
new file mode 100644
index 0000000..2d387ed
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd
@@ -0,0 +1,22 @@
+class_name EqualsArgumentMatcher
+extends GdUnitArgumentMatcher
+
+var _current :Variant
+var _auto_deep_check_mode :bool
+
+
+func _init(current :Variant, auto_deep_check_mode := false) -> void:
+ _current = current
+ _auto_deep_check_mode = auto_deep_check_mode
+
+
+func is_match(value :Variant) -> bool:
+ var case_sensitive_check := true
+ return GdObjects.equals(_current, value, case_sensitive_check, compare_mode(value))
+
+
+func compare_mode(value :Variant) -> GdObjects.COMPARE_MODE:
+ if _auto_deep_check_mode and is_instance_valid(value):
+ # we do deep check on all InputEvent's
+ return GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST if value is InputEvent else GdObjects.COMPARE_MODE.OBJECT_REFERENCE
+ return GdObjects.COMPARE_MODE.OBJECT_REFERENCE
diff --git a/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd.uid
new file mode 100644
index 0000000..916a23c
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/EqualsArgumentMatcher.gd.uid
@@ -0,0 +1 @@
+uid://diq5b7mgjyplv
diff --git a/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd b/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd
new file mode 100644
index 0000000..d5adc4b
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd
@@ -0,0 +1,13 @@
+## The base class of all argument matchers
+class_name GdUnitArgumentMatcher
+extends RefCounted
+
+
+@warning_ignore("unused_parameter")
+func is_match(value: Variant) -> bool:
+ return true
+
+
+func _to_string() -> String:
+ assert(false, "`_to_string()` Is not implemented!")
+ return ""
diff --git a/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd.uid b/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd.uid
new file mode 100644
index 0000000..b061e03
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/GdUnitArgumentMatcher.gd.uid
@@ -0,0 +1 @@
+uid://dj5ysoawmt5ht
diff --git a/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd b/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd
new file mode 100644
index 0000000..6a70cc1
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd
@@ -0,0 +1,42 @@
+class_name GdUnitArgumentMatchers
+extends RefCounted
+
+const TYPE_ANY = TYPE_MAX + 100
+
+
+static func to_matcher(arguments: Array[Variant], auto_deep_check_mode := false) -> ChainedArgumentMatcher:
+ var matchers: Array[Variant] = []
+ for arg: Variant in arguments:
+ # argument is already a matcher
+ if arg is GdUnitArgumentMatcher:
+ matchers.append(arg)
+ else:
+ # pass argument into equals matcher
+ matchers.append(EqualsArgumentMatcher.new(arg, auto_deep_check_mode))
+ return ChainedArgumentMatcher.new(matchers)
+
+
+static func any() -> GdUnitArgumentMatcher:
+ return AnyArgumentMatcher.new()
+
+
+static func by_type(type: int) -> GdUnitArgumentMatcher:
+ return AnyBuildInTypeArgumentMatcher.new([type])
+
+
+static func by_types(types: PackedInt32Array) -> GdUnitArgumentMatcher:
+ return AnyBuildInTypeArgumentMatcher.new(types)
+
+
+static func any_class(clazz: Object) -> GdUnitArgumentMatcher:
+ return AnyClazzArgumentMatcher.new(clazz)
+
+
+static func is_variant_string_matching(value: Variant) -> GdUnitResult:
+ if value is String or value is StringName:
+ return GdUnitResult.success()
+ if value is GdUnitArgumentMatcher:
+ if str(value) == "any()" or str(value) == "any_string()":
+ return GdUnitResult.success()
+ return GdUnitResult.error("Only 'any()' and 'any_string()' argument matchers are allowed!")
+ return GdUnitResult.error("Only String or StringName types are allowed!")
diff --git a/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd.uid b/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd.uid
new file mode 100644
index 0000000..c17d859
--- /dev/null
+++ b/addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd.uid
@@ -0,0 +1 @@
+uid://ct81jyk5s7osa
diff --git a/addons/gdUnit4/src/mocking/GdUnitMock.gd b/addons/gdUnit4/src/mocking/GdUnitMock.gd
new file mode 100644
index 0000000..c520d92
--- /dev/null
+++ b/addons/gdUnit4/src/mocking/GdUnitMock.gd
@@ -0,0 +1,45 @@
+class_name GdUnitMock
+extends RefCounted
+
+## do call the real implementation
+const CALL_REAL_FUNC = "CALL_REAL_FUNC"
+## do return a default value for primitive types or null
+const RETURN_DEFAULTS = "RETURN_DEFAULTS"
+## do return a default value for primitive types and a fully mocked value for Object types
+## builds full deep mocked object
+const RETURN_DEEP_STUB = "RETURN_DEEP_STUB"
+
+var _value: Variant
+
+
+func _init(value: Variant) -> void:
+ _value = value
+
+
+## Selects the mock to work on, used in combination with [method GdUnitTestSuite.do_return][br]
+## Example:
+## [codeblock]
+## do_return(false).on(myMock).is_selected()
+## [/codeblock]
+func on(obj: Variant) -> Variant:
+ if not GdUnitMock._is_mock_or_spy(obj, "__do_return"):
+ return obj
+ @warning_ignore("unsafe_method_access")
+ return obj.__do_return(_value)
+
+
+## [color=yellow]`checked` is obsolete, use `on` instead [/color]
+func checked(obj :Object) -> Object:
+ push_warning("Using a deprecated function 'checked' use `on` instead")
+ return on(obj)
+
+
+static func _is_mock_or_spy(obj: Variant, func_sig: String) -> bool:
+ if obj is Object and not as_object(obj).has_method(func_sig):
+ push_error("Error: You try to use a non mock or spy!")
+ return false
+ return true
+
+
+static func as_object(value: Variant) -> Object:
+ return value
diff --git a/addons/gdUnit4/src/mocking/GdUnitMock.gd.uid b/addons/gdUnit4/src/mocking/GdUnitMock.gd.uid
new file mode 100644
index 0000000..8470002
--- /dev/null
+++ b/addons/gdUnit4/src/mocking/GdUnitMock.gd.uid
@@ -0,0 +1 @@
+uid://cknx4b72mqfq6
diff --git a/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd
new file mode 100644
index 0000000..537e923
--- /dev/null
+++ b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd
@@ -0,0 +1,186 @@
+class_name GdUnitMockBuilder
+extends GdUnitClassDoubler
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+const MOCK_TEMPLATE :GDScript = preload("res://addons/gdUnit4/src/mocking/GdUnitMockImpl.gd")
+
+
+static func is_push_errors() -> bool:
+ return GdUnitSettings.is_report_push_errors()
+
+
+static func build(clazz :Variant, mock_mode :String, debug_write := false) -> Variant:
+ var push_errors := is_push_errors()
+ if not is_mockable(clazz, push_errors):
+ return null
+ # mocking a scene?
+ if GdObjects.is_scene(clazz):
+ var packed_scene: PackedScene = clazz
+ return mock_on_scene(packed_scene, debug_write)
+ elif typeof(clazz) == TYPE_STRING and str(clazz).ends_with(".tscn"):
+ var packed_scene: PackedScene = load(str(clazz))
+ return mock_on_scene(packed_scene, debug_write)
+ # mocking a script
+ var instance := create_instance(clazz)
+ if instance == null:
+ push_error("Can't create instance of class %s" % clazz)
+ var mock := mock_on_script(instance, clazz, [ "get_script"], debug_write)
+ if not instance is RefCounted:
+ instance.free()
+ if mock == null:
+ return null
+ var mock_instance: Object = mock.new()
+ @warning_ignore("unsafe_method_access")
+ mock_instance.__init(mock, mock_mode)
+ return register_auto_free(mock_instance)
+
+
+static func create_instance(clazz: Variant) -> Object:
+ match typeof(clazz):
+ TYPE_OBJECT:
+ var obj: Object = clazz
+ if clazz is GDScript:
+ var script: GDScript = clazz
+ var args := GdObjects.build_function_default_arguments(script, "_init")
+ return script.callv("new", args)
+ elif obj.is_class("GDScriptNativeClass"):
+ @warning_ignore("unsafe_method_access")
+ return obj.new()
+ TYPE_STRING:
+ var clazz_name: String = clazz
+ if clazz_name.ends_with(".gd"):
+ var script: GDScript = load(clazz_name)
+ var args := GdObjects.build_function_default_arguments(script, "_init")
+ return script.callv("new", args)
+ elif ClassDB.can_instantiate(clazz_name):
+ return ClassDB.instantiate(clazz_name)
+
+ push_error("Can't create a mock validation instance from class: `%s`" % clazz)
+ return null
+
+
+static func mock_on_scene(scene: PackedScene, debug_write: bool) -> Variant:
+ var push_errors := is_push_errors()
+ if not scene.can_instantiate():
+ if push_errors:
+ push_error("Can't instanciate scene '%s'" % scene.resource_path)
+ return null
+ var scene_instance := scene.instantiate()
+ # we can only mock checked a scene with attached script
+ var scene_script: Script = scene_instance.get_script()
+ if scene_script == null:
+ if push_errors:
+ push_error("Can't create a mockable instance for a scene without script '%s'" % scene.resource_path)
+ @warning_ignore("return_value_discarded")
+ GdUnitTools.free_instance(scene_instance)
+ return null
+
+ var script_path := scene_script.get_path()
+ var mock := mock_on_script(scene_instance, script_path, GdUnitClassDoubler.EXLCUDE_SCENE_FUNCTIONS, debug_write)
+ if mock == null:
+ return null
+ scene_instance.set_script(mock)
+ @warning_ignore("unsafe_method_access")
+ scene_instance.__init(mock, GdUnitMock.CALL_REAL_FUNC)
+ return register_auto_free(scene_instance)
+
+
+static func get_class_info(clazz :Variant) -> Dictionary:
+ var clazz_name :String = GdObjects.extract_class_name(clazz).value()
+ var clazz_path := GdObjects.extract_class_path(clazz)
+ return {
+ "class_name" : clazz_name,
+ "class_path" : clazz_path
+ }
+
+
+static func mock_on_script(instance :Object, clazz :Variant, function_excludes :PackedStringArray, debug_write :bool) -> GDScript:
+ var function_doubler := GdUnitMockFunctionDoubler.new()
+ var class_info := get_class_info(clazz)
+ var clazz_name :String = class_info.get("class_name")
+ var clazz_path :PackedStringArray = class_info.get("class_path", [clazz_name])
+ var mock_template := MOCK_TEMPLATE.source_code.format({
+ "instance_id" : abs(instance.get_instance_id()),
+ "gdunit_source_class": clazz_name if clazz_path.is_empty() else clazz_path[0]
+ })
+ var lines := load_template(mock_template, class_info)
+ lines += double_functions(instance, clazz_name, clazz_path, function_doubler, function_excludes)
+ # We disable warning/errors for inferred_declaration
+ if Engine.get_version_info().hex >= 0x40400:
+ lines.insert(0, '@warning_ignore_start("inferred_declaration")')
+ lines.append('@warning_ignore_restore("inferred_declaration")')
+
+ var mock := GDScript.new()
+ mock.source_code = "\n".join(lines)
+ mock.resource_name = "Mock%s_%d.gd" % [clazz_name, Time.get_ticks_msec()]
+ mock.resource_path = "%s/%s" % [GdUnitFileAccess.create_temp_dir("mock"), mock.resource_name]
+
+ if debug_write:
+ @warning_ignore("return_value_discarded")
+ DirAccess.remove_absolute(mock.resource_path)
+ @warning_ignore("return_value_discarded")
+ ResourceSaver.save(mock, mock.resource_path)
+ var error := mock.reload(true)
+ if error != OK:
+ push_error("Critical!!!, MockBuilder error, please contact the developer.")
+ return null
+ return mock
+
+
+static func is_mockable(clazz :Variant, push_errors :bool=false) -> bool:
+ var clazz_type := typeof(clazz)
+ if clazz_type != TYPE_OBJECT and clazz_type != TYPE_STRING:
+ push_error("Invalid clazz type is used")
+ return false
+ # is PackedScene
+ if GdObjects.is_scene(clazz):
+ return true
+ if GdObjects.is_native_class(clazz):
+ return true
+ # verify class type
+ if GdObjects.is_object(clazz):
+ if GdObjects.is_instance(clazz):
+ if push_errors:
+ push_error("It is not allowed to mock an instance '%s', use class name instead, Read 'Mocker' documentation for details" % clazz)
+ return false
+
+ if not GdObjects.can_be_instantiate(clazz):
+ if push_errors:
+ push_error("Can't create a mockable instance for class '%s'" % clazz)
+ return false
+ return true
+ # verify by class name checked registered classes
+ var clazz_name: String = clazz
+ if ClassDB.class_exists(clazz_name):
+ if Engine.has_singleton(clazz_name):
+ if push_errors:
+ push_error("Mocking a singelton class '%s' is not allowed! Read 'Mocker' documentation for details" % clazz_name)
+ return false
+ if not ClassDB.can_instantiate(clazz_name):
+ if push_errors:
+ push_error("Mocking class '%s' is not allowed it cannot be instantiated!" % clazz_name)
+ return false
+ # exclude classes where name starts with a underscore
+ if clazz_name.find("_") == 0:
+ if push_errors:
+ push_error("Can't create a mockable instance for protected class '%s'" % clazz_name)
+ return false
+ return true
+ # at least try to load as a script
+ var clazz_path := clazz_name
+ if not FileAccess.file_exists(clazz_path):
+ if push_errors:
+ push_error("'%s' cannot be mocked for the specified resource path, the resource does not exist" % clazz_name)
+ return false
+ # finally verify is a script resource
+ var resource := load(clazz_path)
+ if resource == null:
+ if push_errors:
+ push_error("'%s' cannot be mocked the script cannot be loaded." % clazz_name)
+ return false
+ # finally check is extending from script
+ return GdObjects.is_script(resource) or GdObjects.is_scene(resource)
+
+
+static func register_auto_free(obj :Variant) -> Variant:
+ return GdUnitThreadManager.get_current_context().get_execution_context().register_auto_free(obj)
diff --git a/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid
new file mode 100644
index 0000000..8380b27
--- /dev/null
+++ b/addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd.uid
@@ -0,0 +1 @@
+uid://bx8ykipeol8m1
diff --git a/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd
new file mode 100644
index 0000000..774ca3e
--- /dev/null
+++ b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd
@@ -0,0 +1,143 @@
+class_name DoubledMockClassSourceClassName
+
+################################################################################
+# internal mocking stuff
+################################################################################
+
+const __INSTANCE_ID := "gdunit_doubler_instance_id_{instance_id}"
+
+
+class GdUnitMockDoublerState:
+ const __SOURCE_CLASS := "{gdunit_source_class}"
+
+ var excluded_methods := PackedStringArray()
+ var working_mode := GdUnitMock.RETURN_DEFAULTS
+ var is_prepare_return := false
+ var return_values := Dictionary()
+ var return_value: Variant = null
+
+
+ func _init(working_mode_ := GdUnitMock.RETURN_DEFAULTS) -> void:
+ working_mode = working_mode_
+
+
+var __mock_state := GdUnitMockDoublerState.new()
+@warning_ignore("unused_private_class_variable")
+var __verifier_instance := GdUnitObjectInteractionsVerifier.new()
+
+
+func __init(__script: GDScript, mock_working_mode: String) -> void:
+ super.set_script(__script)
+ __init_doubler()
+ __mock_state.working_mode = mock_working_mode
+
+
+static func __doubler_state() -> GdUnitMockDoublerState:
+ if Engine.has_meta(__INSTANCE_ID):
+ return Engine.get_meta(__INSTANCE_ID).__mock_state
+ return null
+
+
+func __init_doubler() -> void:
+ Engine.set_meta(__INSTANCE_ID, self)
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE and Engine.has_meta(__INSTANCE_ID):
+ Engine.remove_meta(__INSTANCE_ID)
+
+
+static func __get_verifier() -> GdUnitObjectInteractionsVerifier:
+ return Engine.get_meta(__INSTANCE_ID).__verifier_instance
+
+
+static func __is_prepare_return_value() -> bool:
+ return __doubler_state().is_prepare_return
+
+
+static func __sort_by_argument_matcher(__left_args: Array, __right_args: Array) -> bool:
+ for __index in __left_args.size():
+ var __larg: Variant = __left_args[__index]
+ if __larg is GdUnitArgumentMatcher:
+ return false
+ return true
+
+
+# we need to sort by matcher arguments so that they are all at the end of the list
+static func __sort_dictionary(__unsorted_args: Dictionary) -> Dictionary:
+ # only need to sort if contains more than one entry
+ if __unsorted_args.size() <= 1:
+ return __unsorted_args
+ var __sorted_args: Array = __unsorted_args.keys()
+ __sorted_args.sort_custom(__sort_by_argument_matcher)
+ var __sorted_result := {}
+ for __index in __sorted_args.size():
+ var key :Variant = __sorted_args[__index]
+ __sorted_result[key] = __unsorted_args[key]
+ return __sorted_result
+
+
+static func __save_function_return_value(__func_name: String, __func_args: Array) -> void:
+ var doubler_state := __doubler_state()
+ var mocked_return_value_by_args: Dictionary = doubler_state.return_values.get(__func_name, {})
+
+ mocked_return_value_by_args[__func_args] = doubler_state.return_value
+ doubler_state.return_values[__func_name] = __sort_dictionary(mocked_return_value_by_args)
+ doubler_state.return_value = null
+ doubler_state.is_prepare_return = false
+
+
+static func __is_mocked_args_match(__func_args: Array, __mocked_args: Array) -> bool:
+ var __is_matching := false
+ for __index in __mocked_args.size():
+ var __fuction_args: Array = __mocked_args[__index]
+ if __func_args.size() != __fuction_args.size():
+ continue
+ __is_matching = true
+ for __arg_index in __func_args.size():
+ var __func_arg: Variant = __func_args[__arg_index]
+ var __mock_arg: Variant = __fuction_args[__arg_index]
+ if __mock_arg is GdUnitArgumentMatcher:
+ @warning_ignore("unsafe_method_access")
+ __is_matching = __is_matching and __mock_arg.is_match(__func_arg)
+ else:
+ __is_matching = __is_matching and typeof(__func_arg) == typeof(__mock_arg) and __func_arg == __mock_arg
+ if not __is_matching:
+ break
+ if __is_matching:
+ break
+ return __is_matching
+
+
+static func __return_mock_value(__func_name: String, __func_args: Array, __default_return_value: Variant) -> Variant:
+ var doubler_state := __doubler_state()
+ if not doubler_state.return_values.has(__func_name):
+ return __default_return_value
+ @warning_ignore("unsafe_method_access")
+ var __mocked_args: Array = doubler_state.return_values.get(__func_name).keys()
+ for __index in __mocked_args.size():
+ var __margs: Variant = __mocked_args[__index]
+ if __is_mocked_args_match(__func_args, [__margs]):
+ return doubler_state.return_values[__func_name][__margs]
+ return __default_return_value
+
+
+static func __is_do_not_call_real_func(__func_name: String, __func_args := []) -> bool:
+ var doubler_state := __doubler_state()
+ var __is_call_real_func: bool = doubler_state.working_mode == GdUnitMock.CALL_REAL_FUNC and not doubler_state.excluded_methods.has(__func_name)
+ # do not call real funcions for mocked functions
+ if __is_call_real_func and doubler_state.return_values.has(__func_name):
+ @warning_ignore("unsafe_method_access")
+ var __mocked_args: Array = doubler_state.return_values.get(__func_name).keys()
+ return __is_mocked_args_match(__func_args, __mocked_args)
+ return !__is_call_real_func
+
+
+func __exclude_method_call(exluded_methods: PackedStringArray) -> void:
+ __doubler_state().excluded_methods.append_array(exluded_methods)
+
+
+func __do_return(mock_do_return_value: Variant) -> Object:
+ __doubler_state().return_value = mock_do_return_value
+ __doubler_state().is_prepare_return = true
+ return self
diff --git a/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid
new file mode 100644
index 0000000..fbe29d3
--- /dev/null
+++ b/addons/gdUnit4/src/mocking/GdUnitMockImpl.gd.uid
@@ -0,0 +1 @@
+uid://bsuaeeb5m6lyd
diff --git a/addons/gdUnit4/src/monitor/ErrorLogEntry.gd b/addons/gdUnit4/src/monitor/ErrorLogEntry.gd
new file mode 100644
index 0000000..ebbc93f
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/ErrorLogEntry.gd
@@ -0,0 +1,41 @@
+extends RefCounted
+class_name ErrorLogEntry
+
+
+enum TYPE {
+ SCRIPT_ERROR,
+ PUSH_ERROR,
+ PUSH_WARNING
+}
+
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+
+var _type: TYPE
+var _line: int
+var _message: String
+var _details: String
+
+
+func _init(type: TYPE, line: int, message: String, details: String) -> void:
+ _type = type
+ _line = line
+ _message = message
+ _details = details
+
+
+func _to_string() -> String:
+ return _message
+
+
+static func of_push_warning(line: int, message: String, stack_trace: PackedStringArray) -> ErrorLogEntry:
+ return ErrorLogEntry.new(TYPE.PUSH_WARNING, line, message, "\n".join(stack_trace))
+
+
+static func of_push_error(line: int, message: String, stack_trace: PackedStringArray) -> ErrorLogEntry:
+ return ErrorLogEntry.new(TYPE.PUSH_ERROR, line, message, "\n".join(stack_trace))
+
+
+static func of_script_error(line: int, message: String, stack_trace: PackedStringArray) -> ErrorLogEntry:
+ return ErrorLogEntry.new(TYPE.SCRIPT_ERROR, line, message, "\n".join(stack_trace))
diff --git a/addons/gdUnit4/src/monitor/ErrorLogEntry.gd.uid b/addons/gdUnit4/src/monitor/ErrorLogEntry.gd.uid
new file mode 100644
index 0000000..4025709
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/ErrorLogEntry.gd.uid
@@ -0,0 +1 @@
+uid://ca534u63pypt7
diff --git a/addons/gdUnit4/src/monitor/GdUnitMonitor.gd b/addons/gdUnit4/src/monitor/GdUnitMonitor.gd
new file mode 100644
index 0000000..b6429ca
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GdUnitMonitor.gd
@@ -0,0 +1,24 @@
+# GdUnit Monitoring Base Class
+class_name GdUnitMonitor
+extends RefCounted
+
+var _id :String
+
+# constructs new Monitor with given id
+func _init(p_id :String) -> void:
+ _id = p_id
+
+
+# Returns the id of the monitor to uniqe identify
+func id() -> String:
+ return _id
+
+
+# starts monitoring
+func start() -> void:
+ pass
+
+
+# stops monitoring
+func stop() -> void:
+ pass
diff --git a/addons/gdUnit4/src/monitor/GdUnitMonitor.gd.uid b/addons/gdUnit4/src/monitor/GdUnitMonitor.gd.uid
new file mode 100644
index 0000000..b4875fd
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GdUnitMonitor.gd.uid
@@ -0,0 +1 @@
+uid://bd5amxrxcckjv
diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd b/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd
new file mode 100644
index 0000000..c195760
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd
@@ -0,0 +1,70 @@
+class_name GdUnitOrphanNodeInfo
+extends RefCounted
+
+enum GdUnitOrphanType {
+ member,
+ variable,
+ unknown
+}
+
+
+var _id: int
+var _orphan_type: GdUnitOrphanType
+var _type: String
+var _name: String
+var _script_ref: String
+var _func_ref: String
+var _next: GdUnitOrphanNodeInfo
+
+const text_color := Color.ANTIQUE_WHITE
+const function_color := Color.SKY_BLUE
+const member_variable_color := Color.SALMON
+const engine_type_color := Color.LIGHT_GREEN
+const script_path_color := Color.CORNFLOWER_BLUE
+
+
+func _init(orphan_type: GdUnitOrphanType, id: int, type: String, name: String, script_ref: String, func_ref: String = "") -> void:
+ _orphan_type = orphan_type
+ _id = id
+ _type = type
+ _name = name
+ _script_ref = script_ref
+ _func_ref = func_ref
+
+
+func as_trace(info: GdUnitOrphanNodeInfo, show_orphan_id := true) -> String:
+ var trace := ""
+ if show_orphan_id:
+ trace += "• <%s> Id:%s\n" % [
+ _colored(info._type, engine_type_color),
+ _colored(info._id, engine_type_color)]
+ match info._orphan_type:
+ GdUnitOrphanType.member:
+ return trace + " at %s script: %s" % [
+ _colored(info._name, member_variable_color),
+ _colored(info._script_ref, script_path_color)
+ ] + sub_info(info._next)
+ GdUnitOrphanType.variable:
+ return trace + " at %s script: %s.%s()" % [
+ _colored(info._name, member_variable_color),
+ _colored(info._script_ref, script_path_color),
+ _colored(info._func_ref, function_color),
+ ]
+ GdUnitOrphanType.unknown:
+ return trace + " %s" % [
+ _colored(info._name, member_variable_color)
+ ]
+
+ _:
+ return trace + " No details available"
+
+
+func sub_info(next: GdUnitOrphanNodeInfo) -> String:
+ if next == null:
+ return ""
+
+ return "\n" + as_trace(next, false)
+
+
+static func _colored(value: Variant, color: Color) -> String:
+ return "[color=%s]%s[/color]" % [color.to_html(), value]
diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd.uid b/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd.uid
new file mode 100644
index 0000000..a74b4e2
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GdUnitOrphanNodeInfo.gd.uid
@@ -0,0 +1 @@
+uid://lc78iyt2b13e
diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd
new file mode 100644
index 0000000..05f11cd
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd
@@ -0,0 +1,221 @@
+class_name GdUnitOrphanNodesMonitor
+extends GdUnitMonitor
+
+const excluded_frame_files: PackedStringArray = [
+ "GdUnitOrphanNodesMonitor",
+ "GdUnitExecutionContext",
+ "_TestCase",
+ "IGdUnitExecutionStage",
+ "GdUnitTestCaseSingleTestStage",
+ "GdUnitTestCaseSingleExecutionStage",
+ "GdUnitTestCaseExecutionStage",
+ "GdUnitTestSuiteExecutionStage",
+ "GdUnitTestSuiteExecutor"
+]
+
+var _child_monitors: Array[GdUnitOrphanNodesMonitor] = []
+var _orphan_detection_enabled :bool
+var _initial_orphans: Array[int] = []
+var _orphan_ids_at_start: Array[int] = []
+var _orphan_ids_at_stop: Array[int] = []
+var _collected_orphan_infos: Array[GdUnitOrphanNodeInfo] = []
+
+
+func _init(name: String) -> void:
+ super("OrphanNodesMonitor:" + name)
+ _orphan_detection_enabled = GdUnitSettings.is_verbose_orphans()
+ _initial_orphans = _get_orphan_node_ids()
+
+
+func add_child_monitor(monitor: GdUnitOrphanNodesMonitor) -> void:
+ if not _orphan_detection_enabled:
+ return
+ _child_monitors.append(monitor)
+
+
+func start() -> void:
+ if not _orphan_detection_enabled:
+ return
+ _collected_orphan_infos.clear()
+ # Collect current orphan id's to be filtered out at `stop`
+ _orphan_ids_at_start = _get_orphan_node_ids()
+
+
+func stop() -> void:
+ if not _orphan_detection_enabled:
+ return
+ # Collect only new detected orphan id's, we want only to collect orphans between start and stop time
+ _orphan_ids_at_stop = _get_orphan_node_ids().filter(func(element: int) -> bool:
+ # Excluding sub monitores orphans
+ if _collect_child_orphan_ids().has(element):
+ return false
+ # Excluding orphans at start
+ return not _orphan_ids_at_start.has(element) and not _initial_orphans.has(element)
+ )
+
+
+func _collect_child_orphan_ids() -> Array[int]:
+ var collected_ids: Array[int] = []
+ for child_monitor in _child_monitors:
+ collected_ids.append_array(child_monitor._orphan_ids_at_stop)
+ collected_ids.append_array(child_monitor._collect_child_orphan_ids())
+ return collected_ids
+
+
+func detected_orphans() -> Array[GdUnitOrphanNodeInfo]:
+ if not _orphan_detection_enabled:
+ return []
+ return _collected_orphan_infos.filter(func(info: GdUnitOrphanNodeInfo) -> bool:
+ return info._id in _orphan_ids_at_stop
+ )
+
+
+func orphans_count() -> int:
+ if not _orphan_detection_enabled:
+ return 0
+ return _orphan_ids_at_stop.size()
+
+
+func collect() -> void:
+ if not _orphan_detection_enabled:
+ return
+ for orphan_id in _get_orphan_node_ids():
+ var orphan_to_find := instance_from_id(orphan_id)
+ _collect_orphan_info(orphan_to_find)
+
+
+func _collect_orphan_info(orphan_to_find: Object) -> void:
+ if orphan_to_find == null:
+ return
+
+ var orphan_node := _find_orphan_on_backtraces(orphan_to_find)
+ if orphan_node:
+ _collected_orphan_infos.append(orphan_node)
+ return
+
+ if Engine.has_meta("GdUnitSceneRunner"):
+ var current_scene_runner:GdUnitSceneRunner = Engine.get_meta("GdUnitSceneRunner")
+ if is_instance_valid(current_scene_runner):
+ orphan_node = _find_orphan_at_node(orphan_to_find, current_scene_runner.scene())
+ if orphan_node:
+ _collected_orphan_infos.append(orphan_node)
+ return
+
+ # not able to find the orphan node via backtrace loaded nodeds
+ var message := "No details found. Verify called functions manually."
+ if not EngineDebugger.is_active():
+ message = "No details available. [color=yellow]Run tests in debug mode to collect details.[/color]"
+
+ _collected_orphan_infos.append(GdUnitOrphanNodeInfo.new(
+ GdUnitOrphanNodeInfo.GdUnitOrphanType.unknown,
+ orphan_to_find.get_instance_id(),
+ orphan_to_find.get_class(),
+ message,
+ ""))
+
+
+func _find_orphan_at_node(orphan_to_find: Object, node: Node) -> GdUnitOrphanNodeInfo:
+ var script: Script = node.get_script()
+ if script is not GDScript:
+ return null
+
+ # First search over all properties
+ for property in script.get_script_property_list():
+ var property_name: String = property["name"]
+ var property_type: int = property["type"]
+ # Is untyped or type object
+ if property_type in [TYPE_NIL, TYPE_OBJECT]:
+ var property_instance: Variant = node.get(property_name)
+ @warning_ignore("unsafe_cast")
+ var property_as_node := property_instance as Node if property_instance != null else null
+ if property_as_node == null:
+ continue
+ if property_as_node == orphan_to_find:
+ return GdUnitOrphanNodeInfo.new(
+ GdUnitOrphanNodeInfo.GdUnitOrphanType.member,
+ orphan_to_find.get_instance_id(),
+ orphan_to_find.get_class(),
+ property_name,
+ script.resource_path)
+
+ # Search on node childs
+ var orphan_node_info := _find_orphan_at_node(orphan_to_find, property_as_node)
+ if orphan_node_info:
+ orphan_node_info._next = GdUnitOrphanNodeInfo.new(
+ GdUnitOrphanNodeInfo.GdUnitOrphanType.member,
+ orphan_to_find.get_instance_id(),
+ orphan_to_find.get_class(),
+ property_name,
+ script.resource_path)
+ return orphan_node_info
+
+ # Second over all children
+ for child_node in node.get_children():
+ var orphan_node_info := _find_orphan_at_node(orphan_to_find, child_node)
+ if orphan_node_info:
+ return orphan_node_info
+ return null
+
+
+func _is_frame_file_excluded(frame_file: String) -> bool:
+ for file in excluded_frame_files:
+ if frame_file.contains(file):
+ return true
+ return false
+
+
+func _find_orphan_on_backtraces(orphan_to_find: Object) -> GdUnitOrphanNodeInfo:
+ for script_backtrace in Engine.capture_script_backtraces(true):
+ for frame in script_backtrace.get_frame_count():
+ var frame_file := script_backtrace.get_frame_file(frame)
+ if _is_frame_file_excluded(frame_file):
+ continue
+
+ # Scan function variables
+ for l_index in script_backtrace.get_local_variable_count(frame):
+ var variable_instance: Variant = script_backtrace.get_local_variable_value(frame, l_index)
+ var variable_name := script_backtrace.get_local_variable_name(frame, l_index)
+ if typeof(variable_instance) in [TYPE_NIL, TYPE_OBJECT]:
+ @warning_ignore("unsafe_cast")
+ var node := variable_instance as Node
+ if node == null:
+ continue
+ if variable_instance == orphan_to_find:
+ return GdUnitOrphanNodeInfo.new(
+ GdUnitOrphanNodeInfo.GdUnitOrphanType.variable,
+ orphan_to_find.get_instance_id(),
+ orphan_to_find.get_class(),
+ variable_name,
+ script_backtrace.get_frame_file(frame),
+ script_backtrace.get_frame_function(frame))
+ else:
+ var orphan_node_info := _find_orphan_at_node(orphan_to_find, node)
+ if orphan_node_info:
+ return orphan_node_info
+
+ # Scan class members
+ for m_index in script_backtrace.get_member_variable_count(frame):
+ var member_instance: Variant = script_backtrace.get_member_variable_value(frame, m_index)
+ var member_name := script_backtrace.get_member_variable_name(frame, m_index)
+ if typeof(member_instance) in [TYPE_NIL, TYPE_OBJECT]:
+ @warning_ignore("unsafe_cast")
+ var node := member_instance as Node
+ if node == null:
+ continue
+ if member_instance == orphan_to_find:
+ return GdUnitOrphanNodeInfo.new(
+ GdUnitOrphanNodeInfo.GdUnitOrphanType.member,
+ orphan_to_find.get_instance_id(),
+ orphan_to_find.get_class(),
+ member_name,
+ script_backtrace.get_frame_file(frame))
+ else:
+ var orphan_node_info := _find_orphan_at_node(orphan_to_find, node)
+ if orphan_node_info:
+ return orphan_node_info
+ return null
+
+
+static func _get_orphan_node_ids() -> Array[int]:
+ @warning_ignore("unsafe_property_access", "unsafe_method_access")
+ return Engine.get_main_loop().root.get_orphan_node_ids()
diff --git a/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid
new file mode 100644
index 0000000..42c79c7
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GdUnitOrphanNodesMonitor.gd.uid
@@ -0,0 +1 @@
+uid://ckqlijgdcnfno
diff --git a/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd
new file mode 100644
index 0000000..b1b28d7
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd
@@ -0,0 +1,122 @@
+class_name GodotGdErrorMonitor
+extends GdUnitMonitor
+
+
+var _logger: GdUnitLogger
+
+
+class GdUnitLogger extends Logger:
+ var _entries: Array[ErrorLogEntry] = []
+ var _line_number: int
+ var _is_report_push_errors: bool
+ var _is_report_script_errors: bool
+
+
+ func _init(is_report_push_errors: bool, is_report_script_errors: bool) -> void:
+ _is_report_push_errors = is_report_push_errors
+ _is_report_script_errors = is_report_script_errors
+ OS.add_logger(self)
+
+
+ func entries() -> Array[ErrorLogEntry]:
+ return _entries
+
+ func erase_log_entry(log_entry: ErrorLogEntry) -> void:
+ for entry in _entries:
+ if entry._type == log_entry._type and entry._message == log_entry._message:
+ _entries.erase(entry)
+ return
+
+
+ func _log_error(
+ _function: String,
+ _file: String,
+ _line: int,
+ message: String,
+ _rationale: String,
+ _editor_notify: bool,
+ error_type: int,
+ script_backtraces: Array[ScriptBacktrace]
+ ) -> void:
+ match error_type:
+ ErrorType.ERROR_TYPE_WARNING:
+ if _is_report_push_errors:
+ var stack_trace := _build_stack_trace(script_backtraces)
+ _entries.append(ErrorLogEntry.of_push_warning(_line_number, message, stack_trace))
+
+ ErrorType.ERROR_TYPE_ERROR:
+ if _is_report_push_errors:
+ var stack_trace := _build_stack_trace(script_backtraces)
+ _entries.append(ErrorLogEntry.of_push_error(_line_number, message, stack_trace))
+
+ ErrorType.ERROR_TYPE_SCRIPT:
+ if _is_report_script_errors:
+ var stack_trace := _build_stack_trace(script_backtraces)
+ _entries.append(ErrorLogEntry.of_script_error(_line_number, message, stack_trace))
+
+ ErrorType.ERROR_TYPE_SHADER:
+ pass
+ _:
+ prints("Unknwon log type", message)
+
+ func _log_message(_message: String, _error: bool) -> void:
+ pass
+
+ func _build_stack_trace(script_backtraces: Array[ScriptBacktrace]) -> PackedStringArray:
+ for sb in script_backtraces:
+ for frame in sb.get_frame_count():
+ # Find start of test stack
+ if sb.get_frame_file(frame) == "res://addons/gdUnit4/src/core/_TestCase.gd":
+ var stack_trace := PackedStringArray()
+ for test_case_frame in range(0, frame):
+ _line_number = sb.get_frame_line(test_case_frame)
+ stack_trace.append(" at %s:%s" % [sb.get_frame_file(test_case_frame), sb.get_frame_line(test_case_frame)])
+ return stack_trace
+ # if no stack trace collected, we in an await function call
+ var sb := script_backtraces[0]
+ return [" at %s:%s" % [sb.get_frame_file(0), sb.get_frame_line(0)]]
+
+
+func _init() -> void:
+ super("GdUnitLoggerMonitor")
+ _logger = GdUnitLogger.new(GdUnitSettings.is_report_push_errors(), GdUnitSettings.is_report_script_errors())
+
+
+func start() -> void:
+ clear_logs()
+
+
+func stop() -> void:
+ pass
+
+
+func log_entries() -> Array[ErrorLogEntry]:
+ return _logger.entries()
+
+
+func erase_log_entry(log_entry: ErrorLogEntry) -> void:
+ _logger.erase_log_entry(log_entry)
+
+
+func to_reports() -> Array[GdUnitReport]:
+ var reports_: Array[GdUnitReport] = []
+
+ reports_.assign(log_entries().map(_to_report))
+
+ return reports_
+
+
+static func _to_report(errorLog: ErrorLogEntry) -> GdUnitReport:
+ var failure := """
+ %s
+ %s %s
+ %s""".dedent().trim_prefix("\n") % [
+ GdAssertMessages._error("Godot Runtime Error !"),
+ GdAssertMessages._error("Error:"),
+ GdAssertMessages._colored_value(errorLog._message),
+ GdAssertMessages._colored(errorLog._details, GdAssertMessages.VALUE_COLOR)]
+ return GdUnitReport.new().create(GdUnitReport.ABORT, errorLog._line, failure)
+
+
+func clear_logs() -> void:
+ log_entries().clear()
diff --git a/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd.uid b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd.uid
new file mode 100644
index 0000000..1b1e96b
--- /dev/null
+++ b/addons/gdUnit4/src/monitor/GodotGdErrorMonitor.gd.uid
@@ -0,0 +1 @@
+uid://bhessnp2uk5no
diff --git a/addons/gdUnit4/src/network/GdUnitServer.gd b/addons/gdUnit4/src/network/GdUnitServer.gd
new file mode 100644
index 0000000..4ce2f48
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitServer.gd
@@ -0,0 +1,41 @@
+@tool
+extends Node
+
+var _client_id: int
+
+@onready var _server: GdUnitTcpServer = $TcpServer
+
+
+@warning_ignore("return_value_discarded")
+func _ready() -> void:
+ var result := _server.start()
+ if result.is_error():
+ push_error(result.error_message())
+ return
+ var server_port :int = result.value()
+ Engine.set_meta("gdunit_server_port", server_port)
+ _server.client_connected.connect(_on_client_connected)
+ _server.client_disconnected.connect(_on_client_disconnected)
+ _server.rpc_data.connect(_receive_rpc_data)
+
+
+func _on_client_connected(client_id: int) -> void:
+ _client_id = client_id
+ GdUnitSignals.instance().gdunit_client_connected.emit(client_id)
+
+
+func _on_client_disconnected(client_id: int) -> void:
+ GdUnitSignals.instance().gdunit_client_disconnected.emit(client_id)
+
+
+func _receive_rpc_data(p_rpc: RPC) -> void:
+ if p_rpc is RPCMessage:
+ var rpc_message: RPCMessage = p_rpc
+ GdUnitSignals.instance().gdunit_message.emit(rpc_message.message())
+ return
+ if p_rpc is RPCGdUnitEvent:
+ var rpc_event: RPCGdUnitEvent = p_rpc
+ var event := rpc_event.event()
+ GdUnitSignals.instance().gdunit_event.emit(event)
+ if event.type() == GdUnitEvent.SESSION_CLOSE and _server != null:
+ _server.disconnect_client(_client_id)
diff --git a/addons/gdUnit4/src/network/GdUnitServer.gd.uid b/addons/gdUnit4/src/network/GdUnitServer.gd.uid
new file mode 100644
index 0000000..b44864e
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitServer.gd.uid
@@ -0,0 +1 @@
+uid://cu75icxofcpxv
diff --git a/addons/gdUnit4/src/network/GdUnitServer.tscn b/addons/gdUnit4/src/network/GdUnitServer.tscn
new file mode 100644
index 0000000..4dbe8c4
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitServer.tscn
@@ -0,0 +1,10 @@
+[gd_scene load_steps=3 format=3 uid="uid://cn5mp3tmi2gb1"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/network/GdUnitServer.gd" id="1"]
+[ext_resource type="Script" path="res://addons/gdUnit4/src/network/GdUnitTcpServer.gd" id="2"]
+
+[node name="Control" type="Node"]
+script = ExtResource("1")
+
+[node name="TcpServer" type="Node" parent="."]
+script = ExtResource("2")
diff --git a/addons/gdUnit4/src/network/GdUnitServerConstants.gd b/addons/gdUnit4/src/network/GdUnitServerConstants.gd
new file mode 100644
index 0000000..d31eee7
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitServerConstants.gd
@@ -0,0 +1,6 @@
+class_name GdUnitServerConstants
+extends RefCounted
+
+const DEFAULT_SERVER_START_RETRY_TIMES :int = 5
+const GD_TEST_SERVER_PORT :int = 31002
+const JSON_RESPONSE_DELIMITER :String = "<<JRD>>"
diff --git a/addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid b/addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid
new file mode 100644
index 0000000..b847741
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitServerConstants.gd.uid
@@ -0,0 +1 @@
+uid://cg7ssosnfdrcn
diff --git a/addons/gdUnit4/src/network/GdUnitTask.gd b/addons/gdUnit4/src/network/GdUnitTask.gd
new file mode 100644
index 0000000..e0188a0
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTask.gd
@@ -0,0 +1,25 @@
+class_name GdUnitTask
+extends RefCounted
+
+const TASK_NAME = "task_name"
+const TASK_ARGS = "task_args"
+
+var _task_name :String
+var _fref :Callable
+
+
+func _init(task_name :String,instance :Object,func_name :String) -> void:
+ _task_name = task_name
+ if not instance.has_method(func_name):
+ push_error("Can't create GdUnitTask, Invalid func name '%s' for instance '%s'" % [instance, func_name])
+ _fref = Callable(instance, func_name)
+
+
+func name() -> String:
+ return _task_name
+
+
+func execute(args :Array) -> GdUnitResult:
+ if args.is_empty():
+ return _fref.call()
+ return _fref.callv(args)
diff --git a/addons/gdUnit4/src/network/GdUnitTask.gd.uid b/addons/gdUnit4/src/network/GdUnitTask.gd.uid
new file mode 100644
index 0000000..99a1242
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTask.gd.uid
@@ -0,0 +1 @@
+uid://dapl4oygj1lal
diff --git a/addons/gdUnit4/src/network/GdUnitTcpClient.gd b/addons/gdUnit4/src/network/GdUnitTcpClient.gd
new file mode 100644
index 0000000..d6c8a42
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTcpClient.gd
@@ -0,0 +1,126 @@
+@tool
+class_name GdUnitTcpClient
+extends GdUnitTcpNode
+
+signal connection_succeeded(message: String)
+signal connection_failed(message: String)
+
+
+var _client_name: String
+var _debug := false
+var _host: String
+var _port: int
+var _client_id: int
+var _connected: bool
+var _stream: StreamPeerTCP
+
+
+func _init(client_name := "GdUnit4 TCP Client", debug := false) -> void:
+ _client_name = client_name
+ _debug = debug
+
+
+func _ready() -> void:
+ _connected = false
+ _stream = StreamPeerTCP.new()
+ #_stream.set_big_endian(true)
+
+
+func stop() -> void:
+ console("Disconnecting from server")
+ if _stream != null:
+ rpc_send(_stream, RPCClientDisconnect.new().with_id(_client_id))
+ if _stream != null:
+ _stream.disconnect_from_host()
+ _connected = false
+
+
+func start(host: String, port: int) -> GdUnitResult:
+ _host = host
+ _port = port
+ if _connected:
+ return GdUnitResult.warn("Client already connected ... %s:%d" % [_host, _port])
+
+ # Connect client to server
+ if _stream.get_status() != StreamPeerTCP.STATUS_CONNECTED:
+ var err := _stream.connect_to_host(host, port)
+ #prints("connect_to_host", host, port, err)
+ if err != OK:
+ return GdUnitResult.error("GdUnit4: Can't establish client, error code: %s" % err)
+ return GdUnitResult.success("GdUnit4: Client connected checked port %d" % port)
+
+
+func _process(_delta: float) -> void:
+ match _stream.get_status():
+ StreamPeerTCP.STATUS_NONE:
+ return
+
+ StreamPeerTCP.STATUS_CONNECTING:
+ set_process(false)
+ # wait until client is connected to server
+ for retry in 10:
+ @warning_ignore("return_value_discarded")
+ _stream.poll()
+ console("Waiting to connect ..")
+ if _stream.get_status() == StreamPeerTCP.STATUS_CONNECTING:
+ await get_tree().create_timer(0.500).timeout
+ if _stream.get_status() == StreamPeerTCP.STATUS_CONNECTED:
+ set_process(true)
+ return
+ set_process(true)
+ _stream.disconnect_from_host()
+ console("Connection failed")
+ connection_failed.emit("Connect to TCP Server %s:%d faild!" % [_host, _port])
+
+ StreamPeerTCP.STATUS_CONNECTED:
+ if not _connected:
+ var rpc_data :RPC = null
+ set_process(false)
+ while rpc_data == null:
+ await get_tree().create_timer(0.500).timeout
+ rpc_data = rpc_receive()
+ set_process(true)
+ _client_id = (rpc_data as RPCClientConnect).client_id()
+ console("Connected to Server: %d" % _client_id)
+ connection_succeeded.emit("Connect to TCP Server %s:%d success." % [_host, _port])
+ _connected = true
+ process_rpc()
+
+ StreamPeerTCP.STATUS_ERROR:
+ console("Connection failed")
+ _stream.disconnect_from_host()
+ connection_failed.emit("Connect to TCP Server %s:%d faild!" % [_host, _port])
+ return
+
+
+func is_client_connected() -> bool:
+ return _connected
+
+
+func process_rpc() -> void:
+ if _stream.get_available_bytes() > 0:
+ var rpc_data := rpc_receive()
+ if rpc_data is RPCClientDisconnect:
+ console("RPCClientDisconnect")
+ GdUnitSignals.instance().gdunit_test_session_terminate.emit.call_deferred()
+
+
+func send(data: RPC) -> void:
+ rpc_send(_stream, data)
+
+
+func rpc_receive() -> RPC:
+ return receive_packages(_stream).front()
+
+
+func console(value: Variant) -> void:
+ if _debug:
+ print(_client_name, ": ", value)
+
+
+func _on_connection_failed(message: String) -> void:
+ console("Connection faild by: " + message)
+
+
+func _on_connection_succeeded(message: String) -> void:
+ console("Connected: " + message)
diff --git a/addons/gdUnit4/src/network/GdUnitTcpClient.gd.uid b/addons/gdUnit4/src/network/GdUnitTcpClient.gd.uid
new file mode 100644
index 0000000..7166e61
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTcpClient.gd.uid
@@ -0,0 +1 @@
+uid://bitvc4op4ulwh
diff --git a/addons/gdUnit4/src/network/GdUnitTcpNode.gd b/addons/gdUnit4/src/network/GdUnitTcpNode.gd
new file mode 100644
index 0000000..156c764
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTcpNode.gd
@@ -0,0 +1,73 @@
+class_name GdUnitTcpNode
+extends Node
+
+
+func rpc_send(stream: StreamPeerTCP, data: RPC) -> void:
+ var package_buffer := StreamPeerBuffer.new()
+ var buffer := data.serialize().to_utf16_buffer()
+ package_buffer.put_u32(0xDEADBEEF)
+ package_buffer.put_u32(buffer.size())
+ var status_code := package_buffer.put_data(buffer)
+ if status_code != OK:
+ push_error("'rpc_send:' Can't put_data(), error: %s" % error_string(status_code))
+ return
+ stream.put_data(package_buffer.data_array)
+
+
+func receive_packages(stream: StreamPeerTCP, rpc_cb: Callable = noop) -> Array[RPC]:
+ var received_packages: Array[RPC] = []
+ var package_buffer := StreamPeerBuffer.new()
+ if stream.get_status() != StreamPeerTCP.STATUS_CONNECTED:
+ return received_packages
+
+ while stream.get_status() == StreamPeerTCP.STATUS_CONNECTED and stream.get_available_bytes() > 0:
+ var buffer := stream.get_data(8)
+ var status_code: int = buffer[0]
+ if status_code != OK:
+ push_error("'receive_packages:' Can't get_data(%d) for available_bytes, error: %s"
+ % [stream.get_available_bytes(), error_string(status_code)])
+ return received_packages
+
+ var data_package: PackedByteArray
+ package_buffer.data_array = buffer[1]
+ package_buffer.seek(0)
+
+ if package_buffer.get_u32() == 0xDEADBEEF:
+ var size := package_buffer.get_u32()
+ if stream.get_status() != StreamPeerTCP.STATUS_CONNECTED:
+ return received_packages
+ if stream.get_available_bytes() < size:
+ prints("size check:",
+ package_buffer.get_size(), ":",
+ package_buffer.get_position(),
+ "to read:",
+ size,
+ "available size:",
+ stream.get_available_bytes())
+ push_error("'receive_packages:' Can't receive data get_data(%d) for package, error: %s" % [size, error_string(status_code)])
+ return received_packages
+
+ buffer = stream.get_data(size)
+ package_buffer.data_array = buffer[1]
+
+ var rpc_data := package_buffer.get_data(size)
+ status_code = rpc_data[0]
+ if status_code != OK:
+ push_error("'receive_packages:' Can't get_data(%d) for package, error: %s" % [size, error_string(status_code)])
+ continue
+ data_package = rpc_data[1]
+ else:
+ data_package = buffer[1]
+
+ var json := data_package.get_string_from_utf16()
+ if json.is_empty():
+ push_warning("json is empty, can't process data")
+ continue
+ var data := RPC.deserialize(json)
+ received_packages.append(data)
+ rpc_cb.call(data)
+ return received_packages
+
+
+static func noop(_rpc_data: RPC) -> void:
+ pass
diff --git a/addons/gdUnit4/src/network/GdUnitTcpNode.gd.uid b/addons/gdUnit4/src/network/GdUnitTcpNode.gd.uid
new file mode 100644
index 0000000..4483fe2
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTcpNode.gd.uid
@@ -0,0 +1 @@
+uid://c8bwg30yoifsu
diff --git a/addons/gdUnit4/src/network/GdUnitTcpServer.gd b/addons/gdUnit4/src/network/GdUnitTcpServer.gd
new file mode 100644
index 0000000..997d6e3
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTcpServer.gd
@@ -0,0 +1,140 @@
+@tool
+class_name GdUnitTcpServer
+extends Node
+
+signal client_connected(client_id: int)
+signal client_disconnected(client_id: int)
+@warning_ignore("unused_signal")
+signal rpc_data(rpc_data: RPC)
+
+var _server: TCPServer
+var _server_name: String
+
+class TcpConnection extends GdUnitTcpNode:
+ var _id: int
+ var _stream: StreamPeerTCP
+
+
+ func _init(tcp_server: TCPServer) -> void:
+ _stream = tcp_server.take_connection()
+ #_stream.set_big_endian(true)
+ _id = _stream.get_instance_id()
+ rpc_send(_stream, RPCClientConnect.new().with_id(_id))
+
+
+ func _ready() -> void:
+ server().client_connected.emit(_id)
+
+
+ func close() -> void:
+ if _stream != null and _stream.get_status() == StreamPeerTCP.STATUS_CONNECTED:
+ _stream.disconnect_from_host()
+ queue_free()
+
+
+ func id() -> int:
+ return _id
+
+
+ func server() -> GdUnitTcpServer:
+ return get_parent()
+
+
+ func _process(_delta: float) -> void:
+ if _stream == null or _stream.get_status() != StreamPeerTCP.STATUS_CONNECTED:
+ return
+ receive_packages(_stream, func(rpc_data: RPC) -> void:
+ server().rpc_data.emit(rpc_data)
+ # is client disconnecting we close the server after a timeout of 1 second
+ if rpc_data is RPCClientDisconnect:
+ close()
+ )
+
+
+ func disconnect_from_server() -> void:
+ if _stream == null or _stream.get_status() != StreamPeerTCP.STATUS_CONNECTED:
+ return
+ rpc_send(_stream, RPCClientDisconnect.new().with_id(_id))
+
+
+ func console(_value: Variant) -> void:
+ #print_debug("TCP Server: ", value)
+ pass
+
+
+func _init(server_name := "GdUnit4 TCP Server") -> void:
+ _server_name = server_name
+ GdUnitSignals.instance().gdunit_test_session_terminate.connect(func() -> void:
+ for connection in get_children():
+ if connection is TcpConnection:
+ (connection as TcpConnection).disconnect_from_server()
+ )
+
+
+func _ready() -> void:
+ _server = TCPServer.new()
+ client_connected.connect(_on_client_connected)
+ client_disconnected.connect(_on_client_disconnected)
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE:
+ stop()
+
+
+func start(server_port := GdUnitServerConstants.GD_TEST_SERVER_PORT) -> GdUnitResult:
+ var err := OK
+ for retry in GdUnitServerConstants.DEFAULT_SERVER_START_RETRY_TIMES:
+ err = _server.listen(server_port, "127.0.0.1")
+ if err != OK:
+ prints("GdUnit4: Can't establish server checked port: %d, Error: %s" % [server_port, error_string(err)])
+ server_port += 1
+ prints("GdUnit4: Retry (%d) ..." % retry)
+ else:
+ break
+ if err != OK:
+ if err == ERR_ALREADY_IN_USE:
+ return GdUnitResult.error("GdUnit4: Can't establish server, the server is already in use. Error: %s, " % error_string(err))
+ return GdUnitResult.error("GdUnit4: Can't establish server. Error: %s." % error_string(err))
+ console("Successfully started checked port: %d" % server_port)
+ return GdUnitResult.success(server_port)
+
+
+func stop() -> void:
+ if _server:
+ _server.stop()
+ for connection in get_children():
+ if connection is TcpConnection:
+ @warning_ignore("unsafe_method_access")
+ connection.close()
+ remove_child(connection)
+ _server = null
+
+
+func disconnect_client(client_id: int) -> void:
+ client_disconnected.emit(client_id)
+
+
+func _process(_delta: float) -> void:
+ if _server != null and not _server.is_listening():
+ return
+ # check if connection is ready to be used
+ if _server != null and _server.is_connection_available():
+ add_child(TcpConnection.new(_server))
+
+
+func _on_client_connected(client_id: int) -> void:
+ console("Client connected %d" % client_id)
+
+
+func _on_client_disconnected(client_id: int) -> void:
+ for connection in get_children():
+ @warning_ignore("unsafe_method_access")
+ if connection is TcpConnection and connection.id() == client_id:
+ @warning_ignore("unsafe_method_access")
+ connection.close()
+ remove_child(connection)
+
+
+func console(value: Variant) -> void:
+ print(_server_name, ": ", value)
diff --git a/addons/gdUnit4/src/network/GdUnitTcpServer.gd.uid b/addons/gdUnit4/src/network/GdUnitTcpServer.gd.uid
new file mode 100644
index 0000000..76f0fc0
--- /dev/null
+++ b/addons/gdUnit4/src/network/GdUnitTcpServer.gd.uid
@@ -0,0 +1 @@
+uid://csm58erfvsxld
diff --git a/addons/gdUnit4/src/network/rpc/RPC.gd b/addons/gdUnit4/src/network/rpc/RPC.gd
new file mode 100644
index 0000000..6569cb1
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPC.gd
@@ -0,0 +1,37 @@
+class_name RPC
+extends RefCounted
+
+
+var _data: Dictionary = {}
+
+
+func _init(obj: Object = null) -> void:
+ if obj != null:
+ if obj.has_method("serialize"):
+ _data = obj.call("serialize")
+ else:
+ _data = inst_to_dict(obj)
+
+
+func get_data() -> Object:
+ return dict_to_inst(_data)
+
+
+func serialize() -> String:
+ return JSON.stringify(inst_to_dict(self))
+
+
+# using untyped version see comments below
+static func deserialize(json_value: String) -> Object:
+ var json := JSON.new()
+ var err := json.parse(json_value)
+ if err != OK:
+ push_error("Can't deserialize JSON, error at line %d:\n error: %s \n json: '%s'"
+ % [json.get_error_line(), json.get_error_message(), json_value])
+ return null
+ var result: Dictionary = json.get_data()
+ if not typeof(result) == TYPE_DICTIONARY:
+ push_error("Can't deserialize JSON. Expecting dictionary, error at line %d:\n error: %s \n json: '%s'"
+ % [result.error_line, result.error_string, json_value])
+ return null
+ return dict_to_inst(result)
diff --git a/addons/gdUnit4/src/network/rpc/RPC.gd.uid b/addons/gdUnit4/src/network/rpc/RPC.gd.uid
new file mode 100644
index 0000000..40c58dc
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPC.gd.uid
@@ -0,0 +1 @@
+uid://na86vw3ufqjo
diff --git a/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd b/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd
new file mode 100644
index 0000000..6b494cf
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd
@@ -0,0 +1,13 @@
+class_name RPCClientConnect
+extends RPC
+
+var _client_id: int
+
+
+func with_id(id: int) -> RPCClientConnect:
+ _client_id = id
+ return self
+
+
+func client_id() -> int:
+ return _client_id
diff --git a/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd.uid b/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd.uid
new file mode 100644
index 0000000..016194f
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPCClientConnect.gd.uid
@@ -0,0 +1 @@
+uid://e0qdfuxqc6y0
diff --git a/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd b/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd
new file mode 100644
index 0000000..7445b9d
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd
@@ -0,0 +1,13 @@
+class_name RPCClientDisconnect
+extends RPC
+
+var _client_id: int
+
+
+func with_id(id: int) -> RPCClientDisconnect:
+ _client_id = id
+ return self
+
+
+func client_id() -> int:
+ return _client_id
diff --git a/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd.uid b/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd.uid
new file mode 100644
index 0000000..3114387
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPCClientDisconnect.gd.uid
@@ -0,0 +1 @@
+uid://brg4jjgbo7okh
diff --git a/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd b/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd
new file mode 100644
index 0000000..dbf55c6
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd
@@ -0,0 +1,14 @@
+class_name RPCGdUnitEvent
+extends RPC
+
+
+static func of(p_event: GdUnitEvent) -> RPCGdUnitEvent:
+ return RPCGdUnitEvent.new(p_event)
+
+
+func event() -> GdUnitEvent:
+ return GdUnitEvent.new().deserialize(_data)
+
+
+func _to_string() -> String:
+ return "RPCGdUnitEvent: " + str(_data)
diff --git a/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd.uid b/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd.uid
new file mode 100644
index 0000000..2543b41
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPCGdUnitEvent.gd.uid
@@ -0,0 +1 @@
+uid://co8lhd0kirsur
diff --git a/addons/gdUnit4/src/network/rpc/RPCMessage.gd b/addons/gdUnit4/src/network/rpc/RPCMessage.gd
new file mode 100644
index 0000000..1db0470
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPCMessage.gd
@@ -0,0 +1,18 @@
+class_name RPCMessage
+extends RPC
+
+var _message: String
+
+
+static func of(msg :String) -> RPCMessage:
+ var rpc := RPCMessage.new()
+ rpc._message = msg
+ return rpc
+
+
+func message() -> String:
+ return _message
+
+
+func _to_string() -> String:
+ return "RPCMessage: " + _message
diff --git a/addons/gdUnit4/src/network/rpc/RPCMessage.gd.uid b/addons/gdUnit4/src/network/rpc/RPCMessage.gd.uid
new file mode 100644
index 0000000..d470686
--- /dev/null
+++ b/addons/gdUnit4/src/network/rpc/RPCMessage.gd.uid
@@ -0,0 +1 @@
+uid://muf7ucwnbkuk
diff --git a/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd
new file mode 100644
index 0000000..b55b964
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd
@@ -0,0 +1,202 @@
+class_name GdUnitReportSummary
+extends RefCounted
+
+var _resource_path: String
+var _name: String
+var _test_count := 0
+var _failure_count := 0
+var _error_count := 0
+var _orphan_count := 0
+var _skipped_count := 0
+var _flaky_count := 0
+var _duration := 0
+var _reports: Array[GdUnitReportSummary] = []
+var _text_formatter: Callable
+
+
+func _init(text_formatter: Callable) -> void:
+ _text_formatter = text_formatter
+
+
+func name() -> String:
+ return _name
+
+
+func path() -> String:
+ return _resource_path.get_base_dir().replace("res://", "")
+
+
+func get_resource_path() -> String:
+ return _resource_path
+
+
+func suite_count() -> int:
+ return _reports.size()
+
+
+func suite_executed_count() -> int:
+ var executed := _reports.size()
+ for report in _reports:
+ if report.test_count() == report.skipped_count():
+ executed -= 1
+ return executed
+
+
+func test_count() -> int:
+ var count := _test_count
+ for report in _reports:
+ count += report.test_count()
+ return count
+
+
+func test_executed_count() -> int:
+ return test_count() - skipped_count()
+
+
+func success_count() -> int:
+ return test_count() - error_count() - failure_count() - flaky_count() - skipped_count()
+
+
+func error_count() -> int:
+ return _error_count
+
+
+func failure_count() -> int:
+ return _failure_count
+
+
+func skipped_count() -> int:
+ return _skipped_count
+
+
+func flaky_count() -> int:
+ return _flaky_count
+
+
+func orphan_count() -> int:
+ return _orphan_count
+
+
+func duration() -> int:
+ return _duration
+
+
+func get_reports() -> Array:
+ return _reports
+
+
+func add_report(report: GdUnitReportSummary) -> void:
+ _reports.append(report)
+
+
+func report_state() -> String:
+ return calculate_state(error_count(), failure_count(), orphan_count(), flaky_count(), skipped_count())
+
+
+func succes_rate() -> String:
+ return calculate_succes_rate(test_count(), error_count(), failure_count())
+
+
+@warning_ignore("shadowed_variable")
+func add_testcase(resource_path: String, suite_name: String, test_name: String) -> void:
+ for report: GdUnitTestSuiteReport in _reports:
+ if report.get_resource_path() == resource_path:
+ var test_report := GdUnitTestCaseReport.new(resource_path, suite_name, test_name, _text_formatter)
+ report.add_or_create_test_report(test_report)
+
+
+func add_reports(
+ p_resource_path: String,
+ p_test_name: String,
+ p_reports: Array[GdUnitReport]) -> void:
+
+ for report:GdUnitTestSuiteReport in _reports:
+ if report.get_resource_path() == p_resource_path:
+ report.add_testcase_reports(p_test_name, p_reports)
+
+
+func add_testsuite_report(p_resource_path: String, p_suite_name: String, p_test_count: int) -> void:
+ _reports.append(GdUnitTestSuiteReport.new(p_resource_path, p_suite_name, p_test_count, _text_formatter))
+
+
+func add_testsuite_reports(
+ p_resource_path: String,
+ p_reports: Array = []) -> void:
+
+ for report:GdUnitTestSuiteReport in _reports:
+ if report.get_resource_path() == p_resource_path:
+ report.set_reports(p_reports)
+
+
+func set_counters(
+ p_resource_path: String,
+ p_test_name: String,
+ p_error_count: int,
+ p_failure_count: int,
+ p_orphan_count: int,
+ p_is_skipped: bool,
+ p_is_flaky: bool,
+ p_duration: int) -> void:
+
+ for report: GdUnitTestSuiteReport in _reports:
+ if report.get_resource_path() == p_resource_path:
+ report.set_testcase_counters(p_test_name, p_error_count, p_failure_count, p_orphan_count,
+ p_is_skipped, p_is_flaky, p_duration)
+
+
+func update_testsuite_counters(
+ p_resource_path: String,
+ p_error_count: int,
+ p_failure_count: int,
+ p_orphan_count: int,
+ p_skipped_count: int,
+ p_flaky_count: int,
+ p_duration: int) -> void:
+
+ for report:GdUnitTestSuiteReport in _reports:
+ if report.get_resource_path() == p_resource_path:
+ report._update_testsuite_counters(p_error_count, p_failure_count, p_orphan_count, p_skipped_count, p_flaky_count, p_duration)
+ _update_summary_counters(p_error_count, p_failure_count, p_orphan_count, p_skipped_count, p_flaky_count, 0)
+
+
+func _update_summary_counters(
+ p_error_count: int,
+ p_failure_count: int,
+ p_orphan_count: int,
+ p_skipped_count: int,
+ p_flaky_count: int,
+ p_duration: int) -> void:
+
+ _error_count += p_error_count
+ _failure_count += p_failure_count
+ _orphan_count += p_orphan_count
+ _skipped_count += p_skipped_count
+ _flaky_count += p_flaky_count
+ _duration += p_duration
+
+
+func calculate_state(p_error_count :int, p_failure_count :int, p_orphan_count :int, p_flaky_count: int, p_skipped_count: int) -> String:
+ if p_error_count > 0:
+ return "ERROR"
+ if p_failure_count > 0:
+ return "FAILED"
+ if p_flaky_count > 0:
+ return "FLAKY"
+ if p_orphan_count > 0:
+ return "WARNING"
+ if p_skipped_count > 0:
+ return "SKIPPED"
+ return "PASSED"
+
+
+func calculate_succes_rate(p_test_count :int, p_error_count :int, p_failure_count :int) -> String:
+ if p_failure_count == 0:
+ return "100%"
+ var count := p_test_count-p_failure_count-p_error_count
+ if count < 0:
+ return "0%"
+ return "%d" % (( 0 if count < 0 else count) * 100.0 / p_test_count) + "%"
+
+
+func create_summary(_report_dir :String) -> String:
+ return ""
diff --git a/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid
new file mode 100644
index 0000000..6092039
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitReportSummary.gd.uid
@@ -0,0 +1 @@
+uid://koedaj41qehf
diff --git a/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd
new file mode 100644
index 0000000..bf4c96e
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd
@@ -0,0 +1,12 @@
+class_name GdUnitReportWriter
+extends RefCounted
+
+
+func write(_report_path: String, _report: GdUnitReportSummary) -> String:
+ assert(false, "'write' is not implemented!")
+ return ""
+
+
+func output_format() -> String:
+ assert(false, "'output_format' is not implemented!")
+ return ""
diff --git a/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid
new file mode 100644
index 0000000..c9be1dc
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitReportWriter.gd.uid
@@ -0,0 +1 @@
+uid://dcm0384aabejh
diff --git a/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd
new file mode 100644
index 0000000..2801696
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd
@@ -0,0 +1,47 @@
+class_name GdUnitTestCaseReport
+extends GdUnitReportSummary
+
+
+var _suite_name: String
+var _failure_reports: Array[GdUnitReport] = []
+
+
+func _init(p_resource_path: String, p_suite_name: String, p_test_name: String, text_formatter: Callable) -> void:
+ _resource_path = p_resource_path
+ _suite_name = p_suite_name
+ _name = p_test_name
+ _text_formatter = text_formatter
+
+
+func suite_name() -> String:
+ return _suite_name
+
+
+func failure_report() -> String:
+ var report_message := ""
+ for report in get_test_reports():
+ report_message += _text_formatter.call(str(report)) + "\n"
+ return report_message
+
+
+func add_testcase_reports(reports: Array[GdUnitReport]) -> void:
+ _failure_reports.append_array(reports)
+
+
+func set_testcase_counters(
+ p_error_count: int,
+ p_failure_count: int,
+ p_orphan_count: int,
+ p_is_skipped: bool,
+ p_is_flaky: bool,
+ p_duration: int) -> void:
+ _error_count = p_error_count
+ _failure_count = p_failure_count
+ _orphan_count = p_orphan_count
+ _skipped_count = p_is_skipped
+ _flaky_count = p_is_flaky as int
+ _duration = p_duration
+
+
+func get_test_reports() -> Array[GdUnitReport]:
+ return _failure_reports
diff --git a/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid
new file mode 100644
index 0000000..eaf88d0
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitTestCaseReport.gd.uid
@@ -0,0 +1 @@
+uid://dnts7oirmp8ad
diff --git a/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd
new file mode 100644
index 0000000..46b8193
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd
@@ -0,0 +1,112 @@
+class_name GdUnitTestReporter
+extends RefCounted
+
+
+var _statistics := {}
+var _summary := {}
+
+
+func init_summary() -> void:
+ _summary["suite_count"] = 0
+ _summary["total_count"] = 0
+ _summary["error_count"] = 0
+ _summary["failed_count"] = 0
+ _summary["skipped_count"] = 0
+ _summary["flaky_count"] = 0
+ _summary["orphan_nodes"] = 0
+ _summary["elapsed_time"] = 0
+
+
+func init_statistics() -> void:
+ _statistics.clear()
+
+
+func add_test_statistics(event: GdUnitEvent) -> void:
+ _statistics[event.guid()] = {
+ "error_count" : event.error_count(),
+ "failed_count" : event.failed_count(),
+ "skipped_count" : event.skipped_count(),
+ "flaky_count" : event.is_flaky() as int,
+ "orphan_nodes" : event.orphan_nodes()
+ }
+
+
+func build_test_suite_statisitcs(event: GdUnitEvent) -> Dictionary:
+ var statistic := {
+ "total_count" : _statistics.size(),
+ "error_count" : event.error_count(),
+ "failed_count" : event.failed_count(),
+ "skipped_count" : event.skipped_count(),
+ "flaky_count" : 0,
+ "orphan_nodes" : event.orphan_nodes()
+ }
+ _summary["suite_count"] += 1
+ _summary["total_count"] += _statistics.size()
+ _summary["error_count"] += event.error_count()
+ _summary["failed_count"] += event.failed_count()
+ _summary["skipped_count"] += event.skipped_count()
+ _summary["orphan_nodes"] += event.orphan_nodes()
+ _summary["elapsed_time"] += event.elapsed_time()
+
+ for key: String in ["error_count", "failed_count", "skipped_count", "flaky_count", "orphan_nodes"]:
+ var value: int = _statistics.values().reduce(get_value.bind(key), 0 )
+ statistic[key] += value
+ _summary[key] += value
+
+ return statistic
+
+
+func get_value(acc: int, value: Dictionary, key: String) -> int:
+ return acc + value[key]
+
+
+func processed_suite_count() -> int:
+ return _summary["suite_count"]
+
+
+func total_test_count() -> int:
+ return _summary["total_count"]
+
+
+func total_flaky_count() -> int:
+ return _summary["flaky_count"]
+
+
+func total_error_count() -> int:
+ return _summary["error_count"]
+
+
+func total_failure_count() -> int:
+ return _summary["failed_count"]
+
+
+func total_skipped_count() -> int:
+ return _summary["skipped_count"]
+
+
+func total_orphan_count() -> int:
+ return _summary["orphan_nodes"]
+
+
+func elapsed_time() -> int:
+ return _summary["elapsed_time"]
+
+
+func error_count(statistics: Dictionary) -> int:
+ return statistics["error_count"]
+
+
+func failed_count(statistics: Dictionary) -> int:
+ return statistics["failed_count"]
+
+
+func orphan_nodes(statistics: Dictionary) -> int:
+ return statistics["orphan_nodes"]
+
+
+func skipped_count(statistics: Dictionary) -> int:
+ return statistics["skipped_count"]
+
+
+func flaky_count(statistics: Dictionary) -> int:
+ return statistics["flaky_count"]
diff --git a/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid
new file mode 100644
index 0000000..30ffd28
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitTestReporter.gd.uid
@@ -0,0 +1 @@
+uid://5u0wypoy6una
diff --git a/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd
new file mode 100644
index 0000000..9be0682
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd
@@ -0,0 +1,96 @@
+class_name GdUnitTestSuiteReport
+extends GdUnitReportSummary
+
+var _time_stamp: int
+var _failure_reports: Array[GdUnitReport] = []
+
+
+func _init(p_resource_path: String, p_name: String, p_test_count: int, text_formatter: Callable) -> void:
+ _resource_path = p_resource_path
+ _name = p_name
+ _test_count = p_test_count
+ _time_stamp = Time.get_unix_time_from_system() as int
+ _text_formatter = text_formatter
+
+
+func failure_report() -> String:
+ var report_message := ""
+ for report in _failure_reports:
+ report_message += _text_formatter.call(str(report))
+ return report_message
+
+
+func set_duration(p_duration :int) -> void:
+ _duration = p_duration
+
+
+func time_stamp() -> int:
+ return _time_stamp
+
+
+func duration() -> int:
+ return _duration
+
+
+func set_skipped(skipped :int) -> void:
+ _skipped_count += skipped
+
+
+func set_orphans(orphans :int) -> void:
+ _orphan_count = orphans
+
+
+func set_failed(count :int) -> void:
+ _failure_count += count
+
+
+func set_reports(failure_reports :Array[GdUnitReport]) -> void:
+ _failure_reports = failure_reports
+
+
+func add_or_create_test_report(test_report: GdUnitTestCaseReport) -> void:
+ _reports.append(test_report)
+
+
+func _update_testsuite_counters(
+ p_error_count: int,
+ p_failure_count: int,
+ p_orphan_count: int,
+ p_skipped_count: int,
+ p_flaky_count: int,
+ p_duration: int) -> void:
+ _error_count += p_error_count
+ _failure_count += p_failure_count
+ _orphan_count += p_orphan_count
+ _skipped_count += p_skipped_count
+ _flaky_count += p_flaky_count
+ _duration += p_duration
+
+
+func set_testcase_counters(
+ test_name: String,
+ p_error_count: int,
+ p_failure_count: int,
+ p_orphan_count: int,
+ p_is_skipped: bool,
+ p_is_flaky: bool,
+ p_duration: int) -> void:
+ if _reports.is_empty():
+ return
+ var test_report: GdUnitTestCaseReport = _reports.filter(func (report: GdUnitTestCaseReport) -> bool:
+ return report.name() == test_name
+ ).back()
+ if test_report:
+ test_report.set_testcase_counters(p_error_count, p_failure_count, p_orphan_count, p_is_skipped, p_is_flaky, p_duration)
+
+
+func add_testcase_reports(test_name: String, reports: Array[GdUnitReport]) -> void:
+ if reports.is_empty():
+ return
+ # we lookup to latest matching report because of flaky tests could be retry the tests
+ # and resultis in multipe report entries with the same name
+ var test_report: GdUnitTestCaseReport = _reports.filter(func (report: GdUnitTestCaseReport) -> bool:
+ return report.name() == test_name
+ ).back()
+ if test_report:
+ test_report.add_testcase_reports(reports)
diff --git a/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid
new file mode 100644
index 0000000..2784de4
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/GdUnitTestSuiteReport.gd.uid
@@ -0,0 +1 @@
+uid://dyii7lcvudbf8
diff --git a/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd
new file mode 100644
index 0000000..9978deb
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd
@@ -0,0 +1,235 @@
+@tool
+class_name GdUnitConsoleTestReporter
+
+
+var test_session: GdUnitTestSession:
+ get:
+ return test_session
+ set(value):
+ # disconnect first possible connected listener
+ if test_session != null:
+ test_session.test_event.disconnect(on_gdunit_event)
+ # add listening to current session
+ test_session = value
+ if test_session != null:
+ test_session.test_event.connect(on_gdunit_event)
+
+
+var _writer: GdUnitMessageWriter
+var _reporter: GdUnitTestReporter = GdUnitTestReporter.new()
+var _status_indent := 86
+var _detailed: bool
+var _text_color: Color = Color.ANTIQUE_WHITE
+var _function_color: Color = Color.ANTIQUE_WHITE
+var _engine_type_color: Color = Color.ANTIQUE_WHITE
+
+
+func _init(writer: GdUnitMessageWriter, detailed := false) -> void:
+ _writer = writer
+ _writer.clear()
+ _detailed = detailed
+ if _detailed:
+ _status_indent = 20
+ init_colors()
+
+
+func init_colors() -> void:
+ if Engine.is_editor_hint():
+ var settings := EditorInterface.get_editor_settings()
+ _text_color = settings.get_setting("text_editor/theme/highlighting/text_color")
+ _function_color = settings.get_setting("text_editor/theme/highlighting/function_color")
+ _engine_type_color = settings.get_setting("text_editor/theme/highlighting/engine_type_color")
+
+
+func clear() -> void:
+ _writer.clear()
+
+
+func on_gdunit_event(event: GdUnitEvent) -> void:
+ match event.type():
+ GdUnitEvent.INIT:
+ _reporter.init_summary()
+
+ GdUnitEvent.STOP:
+ _print_summary()
+ println_message(build_executed_test_suite_msg(processed_suite_count(), processed_suite_count()), Color.DARK_SALMON)
+ println_message(build_executed_test_case_msg(total_test_count(), total_skipped_count()), Color.DARK_SALMON)
+ println_message("Total execution time: %s" % LocalTime.elapsed(elapsed_time()), Color.DARK_SALMON)
+ # We need finally to set the wave effect to enable the animations
+ _writer.effect(GdUnitMessageWriter.Effect.WAVE).print_at("", 0)
+
+ GdUnitEvent.TESTSUITE_BEFORE:
+ _reporter.init_statistics()
+ print_message("Run Test Suite: ", Color.DARK_TURQUOISE)
+ println_message(event.resource_path(), _engine_type_color)
+
+ GdUnitEvent.TESTSUITE_AFTER:
+ if not event.reports().is_empty():
+ _writer.indent(1).color(_engine_type_color).print_message(event._suite_name)
+ print_message(" > ")
+ print_message("finalize()", _function_color)
+ _print_failure_report(event.reports())
+ _print_statistics(_reporter.build_test_suite_statisitcs(event))
+ _print_status(event)
+ println_message("")
+ if _detailed:
+ println_message("")
+
+ GdUnitEvent.TESTCASE_BEFORE:
+ var test := test_session.find_test_by_id(event.guid())
+ _print_test_path(test, event.guid())
+ if _detailed:
+ _writer.color(Color.FOREST_GREEN).print_at("STARTED", _status_indent)
+ println_message("")
+
+ GdUnitEvent.TESTCASE_AFTER:
+ _reporter.add_test_statistics(event)
+ if _detailed:
+ var test := test_session.find_test_by_id(event.guid())
+ _print_test_path(test, event.guid())
+ _print_status(event)
+ _print_failure_report(event.reports())
+ if _detailed:
+ println_message("")
+
+
+func _print_test_path(test: GdUnitTestCase, uid: GdUnitGUID) -> void:
+ if test == null:
+ prints_warning("Can't print full test info, the test by uid: '%s' was not discovered." % uid)
+ _writer.indent(1).color(_engine_type_color).print_message("Test ID: %s" % uid)
+ return
+
+ var suite_name := test.source_file if _detailed else test.suite_name
+ _writer.indent(1).color(_engine_type_color).print_message(suite_name)
+ print_message(" > ")
+ print_message(test.display_name, _function_color)
+
+
+func _print_status(event: GdUnitEvent) -> void:
+ if event.is_flaky() and event.is_success():
+ var retries: int = event.statistic(GdUnitEvent.RETRY_COUNT)
+ _writer.color(Color.GREEN_YELLOW) \
+ .style(GdUnitMessageWriter.ITALIC) \
+ .print_at("FLAKY (%d retries)" % retries, _status_indent)
+ elif event.is_success():
+ _writer.color(Color.FOREST_GREEN).print_at("PASSED", _status_indent)
+ elif event.is_skipped():
+ _writer.color(Color.GOLDENROD).style(GdUnitMessageWriter.ITALIC).print_at("SKIPPED", _status_indent)
+ elif event.is_failed() or event.is_error():
+ var retries: int = event.statistic(GdUnitEvent.RETRY_COUNT)
+ var message := "FAILED (retry %d)" % retries if retries > 1 else "FAILED"
+ _writer.color(Color.FIREBRICK) \
+ .style(GdUnitMessageWriter.BOLD) \
+ .effect(GdUnitMessageWriter.Effect.WAVE) \
+ .print_at(message, _status_indent)
+ elif event.is_warning():
+ _writer.color(Color.GOLDENROD) \
+ .style(GdUnitMessageWriter.UNDERLINE) \
+ .print_at("WARNING", _status_indent)
+
+ println_message(" %s" % LocalTime.elapsed(event.elapsed_time()), Color.CORNFLOWER_BLUE)
+
+
+func _print_failure_report(reports: Array[GdUnitReport]) -> void:
+ for report in reports:
+ if (
+ report.is_failure()
+ or report.is_error()
+ or report.is_warning()
+ or report.is_skipped()
+ or report.is_orphan()
+ ):
+ _writer.indent(1) \
+ .color(Color.DARK_TURQUOISE) \
+ .style(GdUnitMessageWriter.BOLD | GdUnitMessageWriter.UNDERLINE) \
+ .println_message("Report:")
+ var text := str(report)
+ for line in text.split("\n", false):
+ _writer.indent(2).color(Color.DARK_TURQUOISE).println_message(line)
+
+ if not reports.is_empty():
+ println_message("")
+
+
+func _print_statistics(statistics: Dictionary) -> void:
+ print_message("Statistics:", Color.DODGER_BLUE)
+ print_message(" %d test cases | %d errors | %d failures | %d flaky | %d skipped | %d orphans |" % \
+ [statistics["total_count"],
+ statistics["error_count"],
+ statistics["failed_count"],
+ statistics["flaky_count"],
+ statistics["skipped_count"],
+ statistics["orphan_nodes"]])
+
+
+func _print_summary() -> void:
+ print_message("Overall Summary:", Color.DODGER_BLUE)
+ _writer \
+ .println_message(" %d test cases | %d errors | %d failures | %d flaky | %d skipped | %d orphans |" % [
+ total_test_count(),
+ total_error_count(),
+ total_failure_count(),
+ total_flaky_count(),
+ total_skipped_count(),
+ total_orphan_count()
+ ])
+
+
+func build_executed_test_suite_msg(executed_count: int, total_count: int) -> String:
+ if executed_count == total_count:
+ return "Executed test suites: (%d/%d)" % [executed_count, total_count]
+ return "Executed test suites: (%d/%d), %d skipped" % [executed_count, total_count, (total_count - executed_count)]
+
+
+func build_executed_test_case_msg(total_count: int, p_skipped_count: int) -> String:
+ if p_skipped_count == 0:
+ return "Executed test cases : (%d/%d)" % [total_count, total_count]
+ return "Executed test cases : (%d/%d), %d skipped" % [total_count - p_skipped_count, total_count, p_skipped_count]
+
+
+func print_message(message: String, color: Color = _text_color) -> void:
+ _writer.color(color).print_message(message)
+
+
+func println_message(message: String, color: Color = _text_color) -> void:
+ _writer.color(color).println_message(message)
+
+
+func prints_warning(message: String) -> void:
+ _writer.prints_warning(message)
+
+
+func prints_error(message: String) -> void:
+ _writer.prints_error(message)
+
+
+func total_test_count() -> int:
+ return _reporter.total_test_count()
+
+
+func total_error_count() -> int:
+ return _reporter.total_error_count()
+
+
+func total_failure_count() -> int:
+ return _reporter.total_failure_count()
+
+
+func total_flaky_count() -> int:
+ return _reporter.total_flaky_count()
+
+
+func total_skipped_count() -> int:
+ return _reporter.total_skipped_count()
+
+
+func total_orphan_count() -> int:
+ return _reporter.total_orphan_count()
+
+
+func processed_suite_count() -> int:
+ return _reporter.processed_suite_count()
+
+
+func elapsed_time() -> int:
+ return _reporter.elapsed_time()
diff --git a/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid
new file mode 100644
index 0000000..7ca4b35
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/console/GdUnitConsoleTestReporter.gd.uid
@@ -0,0 +1 @@
+uid://d0bs18q20xbdb
diff --git a/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd
new file mode 100644
index 0000000..74b961b
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd
@@ -0,0 +1,60 @@
+class_name GdUnitByPathReport
+extends GdUnitReportSummary
+
+
+func _init(p_path :String, report_summaries :Array[GdUnitReportSummary]) -> void:
+ _resource_path = p_path
+ _reports = report_summaries
+
+
+# -> Dictionary[String, Array[GdUnitReportSummary]]
+static func sort_reports_by_path(report_summaries :Array[GdUnitReportSummary]) -> Dictionary:
+ var by_path := Dictionary()
+ for report in report_summaries:
+ var suite_path :String = ProjectSettings.localize_path(report.path())
+ var suite_report :Array[GdUnitReportSummary] = by_path.get(suite_path, [] as Array[GdUnitReportSummary])
+ suite_report.append(report)
+ by_path[suite_path] = suite_report
+ return by_path
+
+
+func path() -> String:
+ return _resource_path.replace("res://", "").trim_suffix("/")
+
+
+func create_record(report_link :String) -> String:
+ return GdUnitHtmlPatterns.build(GdUnitHtmlPatterns.TABLE_RECORD_PATH, self, report_link)
+
+
+func write(report_dir :String) -> String:
+ calculate_summary()
+ var template := GdUnitHtmlPatterns.load_template("res://addons/gdUnit4/src/reporters/html/template/folder_report.html")
+ var path_report := GdUnitHtmlPatterns.build(template, self, "")
+ path_report = apply_testsuite_reports(report_dir, path_report, _reports)
+
+ var output_path := "%s/path/%s.html" % [report_dir, path().replace("/", ".")]
+ var dir := output_path.get_base_dir()
+ if not DirAccess.dir_exists_absolute(dir):
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(dir)
+ FileAccess.open(output_path, FileAccess.WRITE).store_string(path_report)
+ return output_path
+
+
+func apply_testsuite_reports(report_dir :String, template :String, test_suite_reports :Array[GdUnitReportSummary]) -> String:
+ var table_records := PackedStringArray()
+ for report:GdUnitTestSuiteReport in test_suite_reports:
+ var report_link := GdUnitHtmlReportWriter.create_output_path(report_dir, report.path(), report.name()).replace(report_dir, "..")
+ @warning_ignore("return_value_discarded")
+ table_records.append(GdUnitHtmlPatterns.create_suite_record(report_link, report))
+ return template.replace(GdUnitHtmlPatterns.TABLE_BY_TESTSUITES, "\n".join(table_records))
+
+
+func calculate_summary() -> void:
+ for report:GdUnitTestSuiteReport in get_reports():
+ _error_count += report.error_count()
+ _failure_count += report.failure_count()
+ _orphan_count += report.orphan_count()
+ _skipped_count += report.skipped_count()
+ _flaky_count += report.flaky_count()
+ _duration += report.duration()
diff --git a/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid
new file mode 100644
index 0000000..607e423
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/GdUnitByPathReport.gd.uid
@@ -0,0 +1 @@
+uid://ct0fnu7t8q2lw
diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd
new file mode 100644
index 0000000..b8be904
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd
@@ -0,0 +1,199 @@
+class_name GdUnitHtmlPatterns
+extends RefCounted
+
+const TABLE_RECORD_TESTSUITE = """
+ <tr class="${report_state}">
+ <td><a href=${report_link}>${testsuite_name}</a></td>
+ <td><span class="status status-${report_state}">${report_state_label}</span></td>
+ <td>${test_count}</td>
+ <td>${skipped_count}</td>
+ <td>${flaky_count}</td>
+ <td>${failure_count}</td>
+ <td>${orphan_count}</td>
+ <td>${duration}</td>
+ <td>
+ <div class="status-bar">
+ <div class="status-bar-column status-skipped" style="width: ${skipped-percent};"></div>
+ <div class="status-bar-column status-passed" style="width: ${passed-percent};"></div>
+ <div class="status-bar-column status-flaky" style="width: ${flaky-percent};"></div>
+ <div class="status-bar-column status-error" style="width: ${error-percent};"></div>
+ <div class="status-bar-column status-failed" style="width: ${failed-percent};"></div>
+ <div class="status-bar-column status-warning" style="width: ${warning-percent};"></div>
+ </div>
+ </td>
+ </tr>
+"""
+
+const TABLE_RECORD_PATH = """
+ <tr class="${report_state}">
+ <td><a class="${report_state}" href="${report_link}">${path}</a></td>
+ <td><span class="status status-${report_state}">${report_state_label}</span></td>
+ <td>${test_count}</td>
+ <td>${skipped_count}</td>
+ <td>${flaky_count}</td>
+ <td>${failure_count}</td>
+ <td>${orphan_count}</td>
+ <td>${duration}</td>
+ <td>
+ <div class="status-bar">
+ <div class="status-bar-column status-skipped" style="width: ${skipped-percent};"></div>
+ <div class="status-bar-column status-passed" style="width: ${passed-percent};"></div>
+ <div class="status-bar-column status-flaky" style="width: ${flaky-percent};"></div>
+ <div class="status-bar-column status-error" style="width: ${error-percent};"></div>
+ <div class="status-bar-column status-failed" style="width: ${failed-percent};"></div>
+ <div class="status-bar-column status-warning" style="width: ${warning-percent};"></div>
+ </div>
+ </td>
+ </tr>
+"""
+
+
+const TABLE_REPORT_TESTSUITE = """
+ <tr class="${report_state}">
+ <td>TestSuite hooks</td>
+ <td>n/a</td>
+ <td>${orphan_count}</td>
+ <td>${duration}</td>
+ <td class="report-column">
+ <pre>
+${failure-report}
+ </pre>
+ </td>
+ </tr>
+"""
+
+
+const TABLE_RECORD_TESTCASE = """
+ <tr class="testcase-group">
+ <td>${testcase_name}</td>
+ <td><span class="status status-${report_state}">${report_state_label}</span></td>
+ <td>${skipped_count}</td>
+ <td>${orphan_count}</td>
+ <td>${duration}</td>
+ <td class="report-column">
+ <pre>
+${failure-report}
+ </pre>
+ </td>
+ </tr>
+"""
+
+const CHARACTERS_TO_ENCODE := {
+ '<' : '&lt;',
+ '>' : '&gt;'
+}
+
+const TABLE_BY_PATHS = "${report_table_paths}"
+const TABLE_BY_TESTSUITES = "${report_table_testsuites}"
+const TABLE_BY_TESTCASES = "${report_table_tests}"
+
+# the report state success, error, warning
+const REPORT_STATE = "${report_state}"
+const REPORT_STATE_LABEL = "${report_state_label}"
+const PATH = "${path}"
+const RESOURCE_PATH = "${resource_path}"
+const TESTSUITE_COUNT = "${suite_count}"
+const TESTCASE_COUNT = "${test_count}"
+const FAILURE_COUNT = "${failure_count}"
+const FLAKY_COUNT = "${flaky_count}"
+const SKIPPED_COUNT = "${skipped_count}"
+const ORPHAN_COUNT = "${orphan_count}"
+const DURATION = "${duration}"
+const FAILURE_REPORT = "${failure-report}"
+const SUCCESS_PERCENT = "${success_percent}"
+
+
+const QUICK_STATE_SKIPPED = "${skipped-percent}"
+const QUICK_STATE_PASSED = "${passed-percent}"
+const QUICK_STATE_FLAKY = "${flaky-percent}"
+const QUICK_STATE_ERROR = "${error-percent}"
+const QUICK_STATE_FAILED = "${failed-percent}"
+const QUICK_STATE_WARNING = "${warning-percent}"
+
+const TESTSUITE_NAME = "${testsuite_name}"
+const TESTCASE_NAME = "${testcase_name}"
+const REPORT_LINK = "${report_link}"
+const BREADCRUMP_PATH_LINK = "${breadcrumb_path_link}"
+const BUILD_DATE = "${buid_date}"
+
+
+static func current_date() -> String:
+ return Time.get_datetime_string_from_system(true, true)
+
+
+static func build(template: String, report: GdUnitReportSummary, report_link: String) -> String:
+ return template\
+ .replace(PATH, get_report_path(report))\
+ .replace(BREADCRUMP_PATH_LINK, get_path_as_link(report))\
+ .replace(RESOURCE_PATH, report.get_resource_path())\
+ .replace(TESTSUITE_NAME, html_encoded(report.name()))\
+ .replace(TESTSUITE_COUNT, str(report.suite_count()))\
+ .replace(TESTCASE_COUNT, str(report.test_count()))\
+ .replace(FAILURE_COUNT, str(report.error_count() + report.failure_count()))\
+ .replace(FLAKY_COUNT, str(report.flaky_count()))\
+ .replace(SKIPPED_COUNT, str(report.skipped_count()))\
+ .replace(ORPHAN_COUNT, str(report.orphan_count()))\
+ .replace(DURATION, LocalTime.elapsed(report.duration()))\
+ .replace(SUCCESS_PERCENT, report.calculate_succes_rate(report.test_count(), report.error_count(), report.failure_count()))\
+ .replace(REPORT_STATE, report.report_state().to_lower())\
+ .replace(REPORT_STATE_LABEL, report.report_state())\
+ .replace(QUICK_STATE_SKIPPED, calculate_percentage(report.test_count(), report.skipped_count()))\
+ .replace(QUICK_STATE_PASSED, calculate_percentage(report.test_count(), report.success_count()))\
+ .replace(QUICK_STATE_FLAKY, calculate_percentage(report.test_count(), report.flaky_count()))\
+ .replace(QUICK_STATE_ERROR, calculate_percentage(report.test_count(), report.error_count()))\
+ .replace(QUICK_STATE_FAILED, calculate_percentage(report.test_count(), report.failure_count()))\
+ .replace(QUICK_STATE_WARNING, calculate_percentage(report.test_count(), 0))\
+ .replace(REPORT_LINK, report_link)\
+ .replace(BUILD_DATE, current_date())
+
+
+static func load_template(template_name :String) -> String:
+ return FileAccess.open(template_name, FileAccess.READ).get_as_text()
+
+
+static func get_path_as_link(report: GdUnitReportSummary) -> String:
+ return "../path/%s.html" % report.path().replace("/", ".")
+
+
+static func get_report_path(report: GdUnitReportSummary) -> String:
+ var path := report.path()
+ if path.is_empty():
+ return "/"
+ return path
+
+
+static func calculate_percentage(p_test_count: int, count: int) -> String:
+ if count <= 0:
+ return "0%"
+ return "%d" % (( 0 if count < 0 else count) * 100.0 / p_test_count) + "%"
+
+
+static func html_encoded(value: String) -> String:
+ for key: String in CHARACTERS_TO_ENCODE.keys():
+ @warning_ignore("unsafe_cast")
+ value = value.replace(key, CHARACTERS_TO_ENCODE[key] as String)
+ return value
+
+
+static func create_suite_record(report_link: String, report: GdUnitTestSuiteReport) -> String:
+ return GdUnitHtmlPatterns.build(GdUnitHtmlPatterns.TABLE_RECORD_TESTSUITE, report, report_link)
+
+
+static func create_test_failure_report(_report_dir :String, report: GdUnitTestCaseReport) -> String:
+ return GdUnitHtmlPatterns.TABLE_RECORD_TESTCASE\
+ .replace(GdUnitHtmlPatterns.REPORT_STATE, report.report_state().to_lower())\
+ .replace(GdUnitHtmlPatterns.REPORT_STATE_LABEL, report.report_state())\
+ .replace(GdUnitHtmlPatterns.TESTCASE_NAME, report.name())\
+ .replace(GdUnitHtmlPatterns.SKIPPED_COUNT, str(report.skipped_count()))\
+ .replace(GdUnitHtmlPatterns.ORPHAN_COUNT, str(report.orphan_count()))\
+ .replace(GdUnitHtmlPatterns.DURATION, LocalTime.elapsed(report._duration))\
+ .replace(GdUnitHtmlPatterns.FAILURE_REPORT, report.failure_report())
+
+
+static func create_suite_failure_report(report: GdUnitTestSuiteReport) -> String:
+ return GdUnitHtmlPatterns.TABLE_REPORT_TESTSUITE\
+ .replace(GdUnitHtmlPatterns.REPORT_STATE, report.report_state().to_lower())\
+ .replace(GdUnitHtmlPatterns.REPORT_STATE_LABEL, report.report_state())\
+ .replace(GdUnitHtmlPatterns.ORPHAN_COUNT, str(report.orphan_count()))\
+ .replace(GdUnitHtmlPatterns.DURATION, LocalTime.elapsed(report._duration))\
+ .replace(GdUnitHtmlPatterns.FAILURE_REPORT, report.failure_report())
diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid
new file mode 100644
index 0000000..aa548b9
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlPatterns.gd.uid
@@ -0,0 +1 @@
+uid://g7tdvdatyesm
diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd
new file mode 100644
index 0000000..a3d2bff
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd
@@ -0,0 +1,72 @@
+class_name GdUnitHtmlReportWriter
+extends GdUnitReportWriter
+
+
+func output_format() -> String:
+ return "HTML"
+
+
+func write(report_path: String, report: GdUnitReportSummary) -> String:
+ var template := GdUnitHtmlPatterns.load_template("res://addons/gdUnit4/src/reporters/html/template/index.html")
+ var to_write := GdUnitHtmlPatterns.build(template, report, "")
+ to_write = _apply_path_reports(report_path, to_write, report.get_reports())
+ to_write = _apply_testsuite_reports(report_path, to_write, report.get_reports())
+ # write report
+ DirAccess.make_dir_recursive_absolute(report_path)
+ var html_report_file := "%s/index.html" % report_path
+ FileAccess.open(html_report_file, FileAccess.WRITE).store_string(to_write)
+ @warning_ignore("return_value_discarded")
+ GdUnitFileAccess.copy_directory("res://addons/gdUnit4/src/reporters/html/template/css/", report_path + "/css")
+ return html_report_file
+
+
+func _apply_path_reports(report_dir: String, template: String, report_summaries: Array) -> String:
+ #Dictionary[String, Array[GdUnitReportSummary]]
+ var path_report_mapping := GdUnitByPathReport.sort_reports_by_path(report_summaries)
+ var table_records := PackedStringArray()
+ var paths: Array[String] = []
+ paths.append_array(path_report_mapping.keys())
+ paths.sort()
+ for report_at_path in paths:
+ var reports: Array[GdUnitReportSummary] = path_report_mapping.get(report_at_path)
+ var report := GdUnitByPathReport.new(report_at_path, reports)
+ var report_link: String = report.write(report_dir).replace(report_dir, ".")
+ @warning_ignore("return_value_discarded")
+ table_records.append(report.create_record(report_link))
+ return template.replace(GdUnitHtmlPatterns.TABLE_BY_PATHS, "\n".join(table_records))
+
+
+func _apply_testsuite_reports(report_dir: String, template: String, test_suite_reports: Array[GdUnitReportSummary]) -> String:
+ var table_records := PackedStringArray()
+ for report: GdUnitTestSuiteReport in test_suite_reports:
+ var report_link: String = _write(report_dir, report).replace(report_dir, ".")
+ @warning_ignore("return_value_discarded")
+ table_records.append(GdUnitHtmlPatterns.create_suite_record(report_link, report))
+ return template.replace(GdUnitHtmlPatterns.TABLE_BY_TESTSUITES, "\n".join(table_records))
+
+
+func _write(report_dir :String, report: GdUnitTestSuiteReport) -> String:
+ var template := GdUnitHtmlPatterns.load_template("res://addons/gdUnit4/src/reporters/html/template/suite_report.html")
+ template = GdUnitHtmlPatterns.build(template, report, "")
+
+ var report_output_path := create_output_path(report_dir, report.path(), report.name())
+ var test_report_table := PackedStringArray()
+ if not report._failure_reports.is_empty():
+ @warning_ignore("return_value_discarded")
+ test_report_table.append(GdUnitHtmlPatterns.create_suite_failure_report(report))
+ for test_report: GdUnitTestCaseReport in report._reports:
+ @warning_ignore("return_value_discarded")
+ test_report_table.append(GdUnitHtmlPatterns.create_test_failure_report(report_output_path, test_report))
+
+ template = template.replace(GdUnitHtmlPatterns.TABLE_BY_TESTCASES, "\n".join(test_report_table))
+
+ var dir := report_output_path.get_base_dir()
+ if not DirAccess.dir_exists_absolute(dir):
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(dir)
+ FileAccess.open(report_output_path, FileAccess.WRITE).store_string(template)
+ return report_output_path
+
+
+static func create_output_path(report_dir :String, path: String, name: String) -> String:
+ return "%s/test_suites/%s.%s.html" % [report_dir, path.replace("/", "."), name]
diff --git a/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid
new file mode 100644
index 0000000..8a9edd6
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/GdUnitHtmlReportWriter.gd.uid
@@ -0,0 +1 @@
+uid://co0bj8mioxbsk
diff --git a/addons/gdUnit4/src/reporters/html/template/.gdignore b/addons/gdUnit4/src/reporters/html/template/.gdignore
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/template/.gdignore
diff --git a/addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css b/addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css
new file mode 100644
index 0000000..17215ff
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/template/css/breadcrumb.css
@@ -0,0 +1,66 @@
+.breadcrumb {
+ display: flex;
+ border-radius: 6px;
+ overflow: hidden;
+ height: 45px;
+ z-index: 1;
+ background-color: #9d73eb;
+ margin-top: 0px;
+ margin-bottom: 10px;
+ box-shadow: 0 0 3px black;
+}
+
+.breadcrumb a {
+ position: relative;
+ display: flex;
+ -ms-flex-positive: 1;
+ flex-grow: 1;
+ text-decoration: none;
+ margin: auto;
+ height: 100%;
+ color: white;
+}
+
+.breadcrumb a:first-child {
+ padding-left: 5.2px;
+}
+
+.breadcrumb a:last-child {
+ padding-right: 5.2px;
+}
+
+.breadcrumb a:after {
+ content: "";
+ position: absolute;
+ display: inline-block;
+ width: 45px;
+ height: 45px;
+ top: 0;
+ right: -20px;
+ background-color: #9d73eb;
+ border-top-right-radius: 5px;
+ transform: scale(0.707) rotate(45deg);
+ box-shadow: 2px -2px rgba(0, 0, 0, 0.25);
+ z-index: 1;
+}
+
+.breadcrumb a:last-child:after {
+ content: none;
+}
+
+.breadcrumb a.active,
+.breadcrumb a:hover {
+ background: #b899f2;
+ color: white;
+ text-decoration: underline;
+}
+
+.breadcrumb a.active:after,
+.breadcrumb a:hover:after {
+ background: #b899f2;
+}
+
+.breadcrumb span {
+ margin: inherit;
+ z-index: 2;
+}
diff --git a/addons/gdUnit4/src/reporters/html/template/css/logo.png b/addons/gdUnit4/src/reporters/html/template/css/logo.png
new file mode 100644
index 0000000..12de79f
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/template/css/logo.png
Binary files differ
diff --git a/addons/gdUnit4/src/reporters/html/template/css/styles.css b/addons/gdUnit4/src/reporters/html/template/css/styles.css
new file mode 100644
index 0000000..e92d59b
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/template/css/styles.css
@@ -0,0 +1,475 @@
+html,
+body {
+ display: flex;
+ flex-direction: column;
+ margin: 0;
+ padding: 0;
+ font-family: sans-serif;
+ background-color: white;
+ height: 100%;
+}
+
+main {
+ flex-grow: 1;
+ overflow: auto;
+ margin: 0 10em;
+}
+
+
+header {
+ color: white;
+ padding: 1px;
+ position: relative;
+ background-image: linear-gradient(to bottom right, #8058e3, #9d73eb);
+}
+
+.logo {
+ position: fixed;
+ top: 20px;
+ left: 20px;
+ display: flex;
+ align-items: center;
+ z-index: 1000;
+ filter: grayscale(1);
+ mix-blend-mode: plus-lighter;
+}
+
+.logo img {
+ width: 64px;
+ height: 64px;
+}
+
+.logo span {
+ font-size: 1.2em;
+ color: lightslategray;
+}
+
+.report-container {
+ margin: 0 15em;
+ text-align: center;
+ margin-top: 60px;
+ flex-grow: 0;
+}
+
+h1 {
+ margin: 0 0 20px 0;
+ font-size: 2.5em;
+ font-weight: normal;
+}
+
+.summary {
+ display: inline-flex;
+ justify-content: center;
+ flex-wrap: nowrap;
+ margin-bottom: 20px;
+ align-items: baseline;
+ max-width: 960px;
+}
+
+.summary-item {
+ flex: 1;
+ min-width: 80px;
+}
+
+.label {
+ font-size: 1em;
+ flex-wrap: nowrap;
+}
+
+.value {
+ font-size: 0.9em;
+ display: block;
+ padding-top: 10px;
+ color: lightgray;
+}
+
+.success-rate {
+ padding-left: 40px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+}
+
+.check-icon {
+ background-color: #34c538;
+ color: white;
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.4em;
+}
+
+.rate-text {
+ text-align: center;
+ flex-wrap: nowrap;
+}
+
+.percentage {
+ font-size: 1.2em;
+ font-weight: bold;
+}
+
+
+nav {
+ padding: 20px 0px;
+ font-family: monospace;
+}
+
+nav ul {
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ justify-content: flex-start;
+ border-bottom: 1px solid lightgray;
+}
+
+nav li {
+ cursor: pointer;
+ padding: 5px 20px;
+ font-size: 1.1em;
+ color: lightslategray;
+}
+
+nav li.active {
+ color: darkslategray;
+ border-bottom: 1px solid darkslategray;
+ font-weight: bold;
+}
+
+div#content {
+ height: calc(100vh - 400px);
+}
+
+
+table {
+ width: 100%;
+ height: 100%;
+ border-collapse: collapse;
+ overflow: hidden;
+}
+
+thead th {
+ position: sticky;
+ top: 0;
+ background-color: white;
+ z-index: 1;
+ border-bottom: 2px solid #ddd;
+}
+
+tbody {
+ display: block;
+ /* Limit the height of the table body */
+ max-height: calc(100vh - 400px);
+ /* Enable scrolling on the table body */
+ overflow-y: auto;
+}
+
+thead,
+tbody tr {
+ display: table;
+ width: 100%;
+ table-layout: fixed;
+}
+
+tbody td {
+ overflow: hidden;
+}
+
+/* Ensure scrollbar visibility */
+tbody::-webkit-scrollbar {
+ height: 4px;
+ width: 14px;
+}
+
+tbody::-webkit-scrollbar-thumb {
+ background-color: #aaa6a6;
+ border-radius: 4px;
+}
+
+tbody::-webkit-scrollbar-track {
+ background-color: #f1f1f1;
+}
+
+th,
+td {
+ font-size: .9em;
+ padding: 5px 0px;
+ border-bottom: 1px solid #eee;
+ color: lightslategrey;
+ text-align: left;
+ text-wrap: nowrap;
+ /* Default max and min width for all columns */
+ max-width: 150px;
+ min-width: 80px;
+ width: 80px;
+}
+
+th {
+ font-size: 1em;
+ font-weight: normal;
+ padding-top: 20px;
+ color: gray;
+ text-wrap: nowrap;
+}
+
+.tab-report {
+ display: grid;
+ grid-template-columns: 100%;
+ margin-bottom: 20px;
+}
+
+.tab-report-grid {
+ display: grid;
+ grid-template-columns: 70% 30%;
+ margin-bottom: 20px;
+}
+
+
+/* Specific styling for the first column (Testcase) */
+th:first-child,
+td:first-child {
+ padding-left: 5px;
+ text-align: left;
+ /* Max width for the first column */
+ min-width: 249px;
+ width: 250px;
+ /* Enable scrollbar if content exceeds max-width */
+ white-space: nowrap;
+ overflow: auto;
+}
+
+/* Scrollbar styles for first column */
+td:first-child {
+ overflow-x: auto;
+ text-overflow: initial;
+}
+
+/* Scrollbar appearance */
+td:first-child::-webkit-scrollbar {
+ height: 6px;
+}
+
+td:first-child::-webkit-scrollbar-thumb {
+ background-color: #888;
+ border-radius: 10px;
+}
+
+td:first-child::-webkit-scrollbar-track {
+ background-color: #f1f1f1;
+}
+
+/* Max width for Result column */
+th:nth-child(2),
+td:nth-child(2) {
+ max-width: 140px;
+ min-width: 140px;
+ width: 140px;
+}
+
+/* Max width for Quick Results column */
+th:nth-child(9),
+td:nth-child(9) {
+ max-width: 140px;
+ min-width: 140px;
+ width: 140px;
+ padding-right: 10px;
+}
+
+/* Background color for alternating groups */
+.group-bg-1 {
+ background-color: #f1f1f1;
+}
+
+.group-bg-2 {
+ background-color: #e0e0e0;
+}
+
+.grid-item {
+ overflow: auto;
+ padding-left: 20px;
+ color: lightslategrey;
+ max-height: calc(100vh - 350px);
+}
+
+div.tab td.report-column,
+th.report-column {
+ display: none;
+}
+
+/* Result status styles */
+.status {
+ padding: 2px 40px;
+ border-radius: 6px;
+ color: black;
+ width: 40px;
+ display: flex;
+ align-content: center;
+ align-items: center;
+}
+
+.status-bar {
+ display: flex;
+ border-radius: 8px;
+ overflow: hidden;
+ height: 20px;
+ flex-wrap: nowrap;
+ justify-content: space-evenly;
+}
+
+.status-bar-column {
+ margin: -2px;
+ color: black;
+ display: flex;
+ align-content: center;
+ align-items: center;
+ transition: width 0.3s ease;
+}
+
+.status-skipped {
+ background-color: #888888;
+}
+
+.status-passed {
+ background-color: #63bb38;
+}
+
+.status-error {
+ background-color: #fd1100;
+}
+
+.status-failed {
+ background-color: #ed594f;
+}
+
+.status-flaky {
+ background-color: #1d9a1f;
+}
+
+.status-warning {
+ background-color: #fdda3f;
+}
+
+div.tab tr:hover {
+ background-color: #d9e7fa;
+ box-shadow: 0 0 5px black;
+}
+
+div.tab tr.selected {
+ background-color: #d9e7fa;
+}
+
+div.report-column {
+ margin-top: 10px;
+ width: 100%;
+ text-align: left;
+}
+
+.logging-container {
+ width: 100%;
+ height: 100%;
+}
+
+div.godot-report-frame {
+ margin: 10px;
+ font-family: monospace;
+ height: 100%;
+ background-color: #eee;
+}
+
+div.include-footer {
+ position: fixed;
+ bottom: 0;
+ width: 100%;
+ display: flex;
+}
+
+footer {
+ position: static;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ white-space: nowrap;
+ color: lightgray;
+ font-size: 12px;
+ background-image: linear-gradient(to bottom right, #8058e3, #9d73eb);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+footer p {
+ padding-left: 10em;
+}
+
+footer .status-legend {
+ display: flex;
+ gap: 15px;
+ width: 500px;
+}
+
+footer a {
+ color: lightgray;
+}
+
+footer a:hover {
+ color: whitesmoke;
+}
+
+footer a:visited {
+ color: whitesmoke;
+}
+
+.status-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.status-box {
+ width: 15px;
+ height: 15px;
+ border-radius: 3px;
+ display: inline-block;
+}
+
+/* Normal link */
+a {
+ color: lightslategrey;
+}
+
+/* Link when hovered */
+a:hover {
+ color: #9d73eb;
+}
+
+/* Visited link */
+a:visited {
+ color: #8058e3;
+}
+
+/* Active link (while being clicked) */
+a:active {
+ color: #8058e3;
+ /* Custom color when link is clicked */
+}
+
+
+@media (max-width: 1024px) {
+ .summary {
+ flex-direction: column;
+ }
+
+ nav ul {
+ flex-wrap: wrap;
+ }
+
+ nav li {
+ margin-right: 10px;
+ margin-bottom: 5px;
+ }
+}
diff --git a/addons/gdUnit4/src/reporters/html/template/folder_report.html b/addons/gdUnit4/src/reporters/html/template/folder_report.html
new file mode 100644
index 0000000..2cdca67
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/template/folder_report.html
@@ -0,0 +1,122 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <meta http-equiv="x-ua-compatible" content="IE=edge" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>GdUnit4 Testsuite</title>
+ <link rel="stylesheet" href="../css/styles.css">
+ <link href="../css/breadcrumb.css" rel="stylesheet" type="text/css" />
+</head>
+
+<body>
+ <header>
+ <div class="logo">
+ <img src="../css/logo.png" alt="GdUnit4 Logo">
+ <span>GdUnit4</span>
+ </div>
+ <div class="report-container">
+ <h1>Report by Paths</h1>
+ <div>
+ <span class="label">${resource_path}</span>
+ </div>
+ <div class="summary">
+ <div class="summary-item">
+ <span class="label">TestSuites</span>
+ <span class="value">${suite_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Tests</span>
+ <span class="value">${test_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Skipped</span>
+ <span class="value">${skipped_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Flaky</span>
+ <span class="value">${flaky_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Failures</span>
+ <span class="value">${failure_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Orphans</span>
+ <span class="value">${orphan_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Duration</span>
+ <span class="value">${duration}</span>
+ </div>
+ <div class="success-rate">
+ <div class="check-icon status-${report_state}">✓</div>
+ <div class="rate-text">
+ <span class="label">Success Rate</span>
+ <span class="value">${success_percent}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="breadcrumb">
+ <a href="../index.html"><span>All</span></a>
+ <a href="${breadcrumb_path_link}"><span>${path}</span></a>
+ <a class="active" href="#"><span>${testsuite_name}</span></a>
+ </div>
+ </header>
+
+ <main>
+ <div>
+ <div class="tab-report">
+ <div class="grid-item tab">
+ <table id="report-table">
+ <thead>
+ <tr>
+ <th>TestSuites</th>
+ <th>Result</th>
+ <th>Tests</th>
+ <th>Skipped</th>
+ <th>Flaky</th>
+ <th>Failures</th>
+ <th>Orphans</th>
+ <th>Duration</th>
+ <th>Success rate</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${report_table_testsuites}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ </main>
+
+ <footer>
+ <p>Generated by <a href="https://github.com/MikeSchulze/gdUnit4">GdUnit4</a> at ${buid_date}</p>
+ <div class="status-legend">
+ <span class="status-legend-item">
+ <span class="status-box status-skipped"></span> Skipped
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-passed"></span> Passed
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-flaky"></span> Flaky
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-warning"></span> Warning
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-failed"></span> Failed
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-error"></span> Error
+ </span>
+ </div>
+ </footer>
+</body>
+
+</html>
diff --git a/addons/gdUnit4/src/reporters/html/template/index.html b/addons/gdUnit4/src/reporters/html/template/index.html
new file mode 100644
index 0000000..342c8f2
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/template/index.html
@@ -0,0 +1,164 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>GdUnit4 Report</title>
+ <link rel="stylesheet" href="css/styles.css">
+</head>
+
+<body>
+ <header>
+ <div class="logo">
+ <img src="css/logo.png" alt="GdUnit4 Logo">
+ <span>GdUnit4</span>
+ </div>
+ <div class="report-container">
+ <h1>Summary Report</h1>
+ <div class="summary">
+ <div class="summary-item">
+ <span class="label">Test Suites</span>
+ <span class="value">${suite_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Tests</span>
+ <span class="value">${test_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Skipped</span>
+ <span class="value">${skipped_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Flaky</span>
+ <span class="value">${flaky_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Failures</span>
+ <span class="value">${failure_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Orphans</span>
+ <span class="value">${orphan_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Duration</span>
+ <span class="value">${duration}</span>
+ </div>
+ <div class="success-rate">
+ <div class="check-icon status-${report_state}">✓</div>
+ <div class="rate-text">
+ <span class="label">Success Rate</span>
+ <span class="value">${success_percent}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </header>
+ <main>
+ <nav>
+ <ul>
+ <li class="active" data-page="test-suites">TEST SUITES</li>
+ <li data-page="paths">PATHS</li>
+ <li data-page="logging">LOGGING</li>
+ </ul>
+ </nav>
+ <div id="content">
+ <!-- Content will be loaded here based on selected page -->
+ </div>
+ </main>
+
+ <footer>
+ <p>Generated by <a href="https://github.com/MikeSchulze/gdUnit4">GdUnit4</a> at ${buid_date}</p>
+ <div class="status-legend">
+ <span class="status-legend-item">
+ <span class="status-box status-skipped"></span> Skipped
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-passed"></span> Passed
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-flaky"></span> Flaky
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-warning"></span> Warning
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-failed"></span> Failed
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-error"></span> Error
+ </span>
+ </div>
+ </footer>
+
+ <script>
+ // Simple JavaScript to handle page switching
+ document.querySelectorAll('nav li').forEach(item => {
+ item.addEventListener('click', function () {
+ document.querySelectorAll('nav li').forEach(li => li.classList.remove('active'));
+ this.classList.add('active');
+ loadPage(this.getAttribute('data-page'));
+ });
+ });
+
+ function loadPage(page) {
+ if (page === 'test-suites') {
+ document.getElementById('content').innerHTML = `
+ <div class="grid-item tab">
+ <table>
+ <thead>
+ <tr>
+ <th>Test Suites</th>
+ <th>State</th>
+ <th>Tests</th>
+ <th>Skipped</th>
+ <th>Flaky</th>
+ <th>Failures</th>
+ <th>Orphans</th>
+ <th>Duration</th>
+ <th>Quick State</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${report_table_testsuites}
+ </tbody>
+ </table>
+ </div>`
+ } else if (page === 'paths') {
+ document.getElementById('content').innerHTML = `
+ <div class="grid-item tab">
+ <table>
+ <thead>
+ <tr>
+ <th>Paths</th>
+ <th>State</th>
+ <th>Tests</th>
+ <th>Skipped</th>
+ <th>Flaky</th>
+ <th>Failures</th>
+ <th>Orphans</th>
+ <th>Duration</th>
+ <th>Quick State</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${report_table_paths}
+ </tbody>
+ </table>
+ </div>`
+ } else if (page === 'logging') {
+ document.getElementById('content').innerHTML = `
+ <h3>${godot_log_file}</h3>
+ <div class="logging-container">
+ <iframe id="logging_content" src="${log_report}" frameborder="0" height="100%" width="100%"></iframe>
+ </div>`
+ }
+ }
+
+ // Load default page
+ loadPage('test-suites');
+ </script>
+</body>
+
+</html>
diff --git a/addons/gdUnit4/src/reporters/html/template/suite_report.html b/addons/gdUnit4/src/reporters/html/template/suite_report.html
new file mode 100644
index 0000000..4746841
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/html/template/suite_report.html
@@ -0,0 +1,177 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <meta http-equiv="x-ua-compatible" content="IE=edge" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>GdUnit4 Testsuite</title>
+ <link rel="stylesheet" href="../css/styles.css">
+ <link href="../css/breadcrumb.css" rel="stylesheet" type="text/css" />
+
+ <script src="https://code.jquery.com/jquery-3.6.4.js"></script>
+ <script>
+ $(document).ready(function () {
+ var report_view = $('#report_area').find('.report-column')
+
+ $('#report-table tr').click(function () {
+ var report = $(this).find('.report-column').html();
+ report_view.html(report)
+ $('.selected').removeClass('selected');
+ $(this).addClass('selected');
+ });
+ });
+ </script>
+</head>
+
+<body>
+ <header>
+ <div class="logo">
+ <img src="../css/logo.png" alt="GdUnit4 Logo">
+ <span>GdUnit4</span>
+ </div>
+ <div class="report-container">
+ <h1>Testsuite Report</h1>
+ <div>
+ <span class="label">${resource_path}</span>
+ </div>
+ <div class="summary">
+ <div class="summary-item">
+ <span class="label">Tests</span>
+ <span class="value">${test_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Skipped</span>
+ <span class="value">${skipped_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Flaky</span>
+ <span class="value">${flaky_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Failures</span>
+ <span class="value">${failure_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Orphans</span>
+ <span class="value">${orphan_count}</span>
+ </div>
+ <div class="summary-item">
+ <span class="label">Duration</span>
+ <span class="value">${duration}</span>
+ </div>
+ <div class="success-rate">
+ <div class="check-icon status-${report_state}">✓</div>
+ <div class="rate-text">
+ <span class="label">Success Rate</span>
+ <span class="value">${success_percent}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="breadcrumb">
+ <a href="../index.html"><span>All</span></a>
+ <a href="${breadcrumb_path_link}"><span>${path}</span></a>
+ <a class="active" href="#"><span>${testsuite_name}</span></a>
+ </div>
+ </header>
+
+ <main>
+ <div class="tab-report-grid">
+ <div class="grid-item tab">
+ <table id="report-table">
+ <thead>
+ <tr>
+ <th>Testcase</th>
+ <th>Result</th>
+ <th>Skipped</th>
+ <th>Orphans</th>
+ <th>Duration</th>
+ <th class="report-column">Report</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${report_table_tests}
+ </tbody>
+ </table>
+ </div>
+ <div id="report_area" class="grid-item tab">
+ <h4>Failure Report</h4>
+ <div class="report-column"></div>
+ </div>
+ </div>
+ </main>
+
+ <footer>
+ <p>Generated by <a href="https://github.com/MikeSchulze/gdUnit4">GdUnit4</a> at ${buid_date}</p>
+ <div class="status-legend">
+ <span class="status-legend-item">
+ <span class="status-box status-skipped"></span> Skipped
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-passed"></span> Passed
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-flaky"></span> Flaky
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-warning"></span> Warning
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-failed"></span> Failed
+ </span>
+ <span class="status-legend-item">
+ <span class="status-box status-error"></span> Error
+ </span>
+ </div>
+ </footer>
+</body>
+
+<script>
+ function groupTableRows() {
+ const table = document.getElementById('report-table');
+ const rows = table.querySelectorAll('tbody tr');
+
+ let previousTestCase = '';
+ let groupStartIndex = 0;
+ let groupColorToggle = true; // Toggle between two colors
+
+ rows.forEach((row, index) => {
+ const testCaseName = row.cells[0].textContent.trim();
+
+ if (testCaseName !== previousTestCase) {
+ // Close the previous group
+ if (index > 0 && groupStartIndex !== index - 1) {
+ // Apply background color to the group
+ const groupClass = groupColorToggle ? 'group-bg-1' : 'group-bg-2';
+ for (let i = groupStartIndex; i <= index - 1; i++) {
+ rows[i].classList.add(groupClass);
+ }
+
+ // Toggle background color for the next group
+ groupColorToggle = !groupColorToggle;
+ }
+
+ // Start a new group
+ groupStartIndex = index;
+ }
+
+ previousTestCase = testCaseName;
+ });
+
+ // Handle the last group
+ if (groupStartIndex !== rows.length - 1) {
+ // Apply background color to the last group
+ const groupClass = groupColorToggle ? 'group-bg-1' : 'group-bg-2';
+ for (let i = groupStartIndex; i < rows.length; i++) {
+ rows[i].classList.add(groupClass);
+ }
+ }
+ }
+
+ // Call the function to group table rows after the DOM loads
+ document.addEventListener('DOMContentLoaded', groupTableRows);
+</script>
+
+</html>
diff --git a/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd
new file mode 100644
index 0000000..0b9674f
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd
@@ -0,0 +1,143 @@
+# This class implements the JUnit XML file format
+# based checked https://github.com/windyroad/JUnit-Schema/blob/master/JUnit.xsd
+class_name JUnitXmlReportWriter
+extends GdUnitReportWriter
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+
+const ATTR_CLASSNAME := "classname"
+const ATTR_ERRORS := "errors"
+const ATTR_FAILURES := "failures"
+const ATTR_HOST := "hostname"
+const ATTR_ID := "id"
+const ATTR_MESSAGE := "message"
+const ATTR_NAME := "name"
+const ATTR_PACKAGE := "package"
+const ATTR_SKIPPED := "skipped"
+const ATTR_FLAKY := "flaky"
+const ATTR_TESTS := "tests"
+const ATTR_TIME := "time"
+const ATTR_TIMESTAMP := "timestamp"
+const ATTR_TYPE := "type"
+
+const HEADER := '<?xml version="1.0" encoding="UTF-8" ?>\n'
+
+
+func output_format() -> String:
+ return "XML"
+
+
+func write(report_path: String, report: GdUnitReportSummary) -> String:
+ var result_file: String = "%s/results.xml" % report_path
+ DirAccess.make_dir_recursive_absolute(report_path)
+ var file := FileAccess.open(result_file, FileAccess.WRITE)
+ if file == null:
+ push_warning("Can't saving the result to '%s'\n Error: %s" % [result_file, error_string(FileAccess.get_open_error())])
+ else:
+ file.store_string(build_junit_report(report_path, report))
+ return result_file
+
+
+func build_junit_report(report_path: String, report: GdUnitReportSummary) -> String:
+ var iso8601_datetime := Time.get_date_string_from_system()
+ var test_suites := XmlElement.new("testsuites")\
+ .attribute(ATTR_ID, iso8601_datetime)\
+ .attribute(ATTR_NAME, report_path.get_file())\
+ .attribute(ATTR_TESTS, report.test_count())\
+ .attribute(ATTR_FAILURES, report.failure_count())\
+ .attribute(ATTR_SKIPPED, report.skipped_count())\
+ .attribute(ATTR_FLAKY, report.flaky_count())\
+ .attribute(ATTR_TIME, JUnitXmlReportWriter.to_time(report.duration()))\
+ .add_childs(build_test_suites(report))
+ var as_string := test_suites.to_xml()
+ test_suites.dispose()
+ return HEADER + as_string
+
+
+func build_test_suites(summary: GdUnitReportSummary) -> Array:
+ var test_suites: Array[XmlElement] = []
+ for index in summary.get_reports().size():
+ var suite_report :GdUnitTestSuiteReport = summary.get_reports()[index]
+ var iso8601_datetime := Time.get_datetime_string_from_unix_time(suite_report.time_stamp())
+ test_suites.append(XmlElement.new("testsuite")\
+ .attribute(ATTR_ID, index)\
+ .attribute(ATTR_NAME, suite_report.name())\
+ .attribute(ATTR_PACKAGE, suite_report.path())\
+ .attribute(ATTR_TIMESTAMP, iso8601_datetime)\
+ .attribute(ATTR_HOST, "localhost")\
+ .attribute(ATTR_TESTS, suite_report.test_count())\
+ .attribute(ATTR_FAILURES, suite_report.failure_count())\
+ .attribute(ATTR_ERRORS, suite_report.error_count())\
+ .attribute(ATTR_SKIPPED, suite_report.skipped_count())\
+ .attribute(ATTR_FLAKY, suite_report.flaky_count())\
+ .attribute(ATTR_TIME, JUnitXmlReportWriter.to_time(suite_report.duration()))\
+ .add_childs(build_test_cases(suite_report)))
+ return test_suites
+
+
+func build_test_cases(suite_report: GdUnitTestSuiteReport) -> Array:
+ var test_cases: Array[XmlElement] = []
+ for index in suite_report.get_reports().size():
+ var report :GdUnitTestCaseReport = suite_report.get_reports()[index]
+ test_cases.append( XmlElement.new("testcase")\
+ .attribute(ATTR_NAME, JUnitXmlReportWriter.encode_xml(report.name()))\
+ .attribute(ATTR_CLASSNAME, report.suite_name())\
+ .attribute(ATTR_TIME, JUnitXmlReportWriter.to_time(report.duration()))\
+ .add_childs(build_reports(report)))
+ return test_cases
+
+
+func build_reports(test_report: GdUnitTestCaseReport) -> Array:
+ var failure_reports: Array[XmlElement] = []
+
+ for report: GdUnitReport in test_report.get_test_reports():
+ if report.is_failure():
+ failure_reports.append(XmlElement.new("failure")\
+ .attribute(ATTR_MESSAGE, "FAILED: %s:%d" % [test_report.get_resource_path(), report.line_number()])\
+ .attribute(ATTR_TYPE, JUnitXmlReportWriter.to_type(report.type()))\
+ .text(convert_rtf_to_text(report.message())))
+ elif report.is_error():
+ failure_reports.append(XmlElement.new("error")\
+ .attribute(ATTR_MESSAGE, "ERROR: %s:%d" % [test_report.get_resource_path(), report.line_number()])\
+ .attribute(ATTR_TYPE, JUnitXmlReportWriter.to_type(report.type()))\
+ .text(convert_rtf_to_text(report.message())))
+ elif report.is_skipped():
+ failure_reports.append(XmlElement.new("skipped")\
+ .attribute(ATTR_MESSAGE, "SKIPPED: %s:%d" % [test_report.get_resource_path(), report.line_number()])\
+ .text(convert_rtf_to_text(report.message())))
+ return failure_reports
+
+
+func convert_rtf_to_text(bbcode: String) -> String:
+ return GdUnitTools.richtext_normalize(bbcode)
+
+
+static func to_type(type: int) -> String:
+ match type:
+ GdUnitReport.SUCCESS:
+ return "SUCCESS"
+ GdUnitReport.WARN:
+ return "WARN"
+ GdUnitReport.FAILURE:
+ return "FAILURE"
+ GdUnitReport.ORPHAN:
+ return "ORPHAN"
+ GdUnitReport.TERMINATED:
+ return "TERMINATED"
+ GdUnitReport.INTERUPTED:
+ return "INTERUPTED"
+ GdUnitReport.ABORT:
+ return "ABORT"
+ return "UNKNOWN"
+
+
+static func to_time(duration: int) -> String:
+ return "%4.03f" % (duration / 1000.0)
+
+
+static func encode_xml(value: String) -> String:
+ return value.xml_escape(true)
+
+
+#static func to_ISO8601_datetime() -> String:
+ #return "%04d-%02d-%02dT%02d:%02d:%02d" % [date["year"], date["month"], date["day"], date["hour"], date["minute"], date["second"]]
diff --git a/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid
new file mode 100644
index 0000000..16ffa18
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/xml/JUnitXmlReportWriter.gd.uid
@@ -0,0 +1 @@
+uid://bf8g04wtq4lfp
diff --git a/addons/gdUnit4/src/reporters/xml/XmlElement.gd b/addons/gdUnit4/src/reporters/xml/XmlElement.gd
new file mode 100644
index 0000000..86c7421
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/xml/XmlElement.gd
@@ -0,0 +1,69 @@
+class_name XmlElement
+extends RefCounted
+
+var _name :String
+# Dictionary[String, String]
+var _attributes :Dictionary = {}
+var _childs :Array[XmlElement] = []
+var _parent :XmlElement = null
+var _text :String = ""
+
+
+func _init(name :String) -> void:
+ _name = name
+
+
+func dispose() -> void:
+ for child in _childs:
+ child.dispose()
+ _childs.clear()
+ _attributes.clear()
+ _parent = null
+
+
+func attribute(name :String, value :Variant) -> XmlElement:
+ _attributes[name] = str(value)
+ return self
+
+
+func text(p_text :String) -> XmlElement:
+ _text = p_text if p_text.ends_with("\n") else p_text + "\n"
+ return self
+
+
+func add_child(child :XmlElement) -> XmlElement:
+ _childs.append(child)
+ child._parent = self
+ return self
+
+
+func add_childs(childs :Array[XmlElement]) -> XmlElement:
+ for child in childs:
+ @warning_ignore("return_value_discarded")
+ add_child(child)
+ return self
+
+
+func indentation() -> String:
+ return "" if _parent == null else _parent.indentation() + " "
+
+
+func to_xml() -> String:
+ var attributes := ""
+ for key in _attributes.keys() as Array[String]:
+ attributes += ' {attr}="{value}"'.format({"attr": key, "value": _attributes.get(key)})
+
+ var childs := ""
+ for child in _childs:
+ childs += child.to_xml()
+
+ return "{_indentation}<{name}{attributes}>\n{childs}{text}{_indentation}</{name}>\n"\
+ .format({"name": _name,
+ "attributes": attributes,
+ "childs": childs,
+ "_indentation": indentation(),
+ "text": cdata(_text)})
+
+
+func cdata(p_text :String) -> String:
+ return "" if p_text.is_empty() else "<![CDATA[\n{text}]]>\n".format({"text" : p_text})
diff --git a/addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid b/addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid
new file mode 100644
index 0000000..f02b6a2
--- /dev/null
+++ b/addons/gdUnit4/src/reporters/xml/XmlElement.gd.uid
@@ -0,0 +1 @@
+uid://bae36annbua3k
diff --git a/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd b/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd
new file mode 100644
index 0000000..5d8b631
--- /dev/null
+++ b/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd
@@ -0,0 +1,154 @@
+class_name GdUnitSpyBuilder
+extends GdUnitClassDoubler
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+const SPY_TEMPLATE :GDScript = preload("res://addons/gdUnit4/src/spy/GdUnitSpyImpl.gd")
+const EXCLUDE_PROPERTIES_TO_COPY = ["script", "type"]
+
+
+static func build(to_spy: Variant, debug_write := false) -> Variant:
+ if GdObjects.is_singleton(to_spy):
+ @warning_ignore("unsafe_cast")
+ push_error("Spy on a Singleton is not allowed! '%s'" % (to_spy as Object).get_class())
+ return null
+
+ # if resource path load it before
+ if GdObjects.is_scene_resource_path(to_spy):
+ var scene_resource_path :String = to_spy
+ if not FileAccess.file_exists(scene_resource_path):
+ push_error("Can't build spy on scene '%s'! The given resource not exists!" % scene_resource_path)
+ return null
+ var scene_to_spy: PackedScene = load(scene_resource_path)
+ return spy_on_scene(scene_to_spy.instantiate() as Node, debug_write)
+ # spy checked PackedScene
+ if GdObjects.is_scene(to_spy):
+ var scene_to_spy: PackedScene = to_spy
+ return spy_on_scene(scene_to_spy.instantiate() as Node, debug_write)
+ # spy checked a scene instance
+ if GdObjects.is_instance_scene(to_spy):
+ @warning_ignore("unsafe_cast")
+ return spy_on_scene(to_spy as Node, debug_write)
+
+ var excluded_functions := []
+ if to_spy is Callable:
+ @warning_ignore("unsafe_cast")
+ to_spy = CallableDoubler.new(to_spy as Callable)
+ excluded_functions = CallableDoubler.excluded_functions()
+
+ var spy := spy_on_script(to_spy, excluded_functions, debug_write)
+ if spy == null:
+ return null
+ var spy_instance: Object = spy.new()
+ @warning_ignore("unsafe_method_access")
+ # we do not call the original implementation for _ready and all input function, this is actualy done by the engine
+ spy_instance.__init(["_input", "_gui_input", "_input_event", "_unhandled_input"])
+ @warning_ignore("unsafe_cast")
+ copy_properties(to_spy as Object, spy_instance)
+ @warning_ignore("return_value_discarded")
+ GdUnitObjectInteractions.reset(spy_instance)
+ return register_auto_free(spy_instance)
+
+
+static func get_class_info(clazz :Variant) -> Dictionary:
+ var clazz_path := GdObjects.extract_class_path(clazz)
+ var clazz_name :String = GdObjects.extract_class_name(clazz).value()
+ return {
+ "class_name" : clazz_name,
+ "class_path" : clazz_path
+ }
+
+
+static func spy_on_script(instance: Variant, function_excludes: PackedStringArray, debug_write: bool) -> GDScript:
+ if GdArrayTools.is_array_type(instance):
+ if GdUnitSettings.is_verbose_assert_errors():
+ push_error("Can't build spy checked type '%s'! Spy checked Container Built-In Type not supported!" % type_string(typeof(instance)))
+ return null
+ var class_info := get_class_info(instance)
+ var clazz_name :String = class_info.get("class_name")
+ var clazz_path :PackedStringArray = class_info.get("class_path", [clazz_name])
+ if not GdObjects.is_instance(instance):
+ if GdUnitSettings.is_verbose_assert_errors():
+ push_error("Can't build spy for class type '%s'! Using an instance instead e.g. 'spy(<instance>)'" % [clazz_name])
+ return null
+
+ @warning_ignore("unsafe_method_access")
+ var spy_template := SPY_TEMPLATE.source_code.format({
+ "instance_id" : abs(instance.get_instance_id()),
+ "gdunit_source_class": clazz_name if clazz_path.is_empty() else clazz_path[0]
+ })
+ @warning_ignore("unsafe_cast")
+ var lines := load_template(spy_template, class_info)
+ @warning_ignore("unsafe_cast")
+ lines += double_functions(instance as Object, clazz_name, clazz_path, GdUnitSpyFunctionDoubler.new(), function_excludes)
+ # We disable warning/errors for inferred_declaration
+ if Engine.get_version_info().hex >= 0x40400:
+ lines.insert(0, '@warning_ignore_start("inferred_declaration")')
+ lines.append('@warning_ignore_restore("inferred_declaration")')
+
+ var spy := GDScript.new()
+ spy.source_code = "\n".join(lines)
+ spy.resource_name = "Spy%s.gd" % clazz_name
+ spy.resource_path = GdUnitFileAccess.create_temp_dir("spy") + "/Spy%s_%d.gd" % [clazz_name, Time.get_ticks_msec()]
+
+ if debug_write:
+ @warning_ignore("return_value_discarded")
+ DirAccess.remove_absolute(spy.resource_path)
+ @warning_ignore("return_value_discarded")
+ ResourceSaver.save(spy, spy.resource_path)
+ var error := spy.reload(true)
+ if error != OK:
+ push_error("Unexpected Error!, SpyBuilder error, please contact the developer.")
+ return null
+ return spy
+
+
+static func spy_on_scene(scene :Node, debug_write :bool) -> Object:
+ if scene.get_script() == null:
+ if GdUnitSettings.is_verbose_assert_errors():
+ push_error("Can't create a spy checked a scene without script '%s'" % scene.get_scene_file_path())
+ return null
+ # buils spy checked original script
+ @warning_ignore("unsafe_cast")
+ var scene_script :Object = (scene.get_script() as GDScript).new()
+ var spy := spy_on_script(scene_script, GdUnitClassDoubler.EXLCUDE_SCENE_FUNCTIONS, debug_write)
+ scene_script.free()
+ if spy == null:
+ return null
+
+ # we need to restore the original script properties to apply after script exchange
+ var original_properties := {}
+ for p in scene.get_property_list():
+ var property_name: String = p["name"]
+ var usage: int = p["usage"]
+ if (usage & PROPERTY_USAGE_SCRIPT_VARIABLE) == PROPERTY_USAGE_SCRIPT_VARIABLE:
+ original_properties[property_name] = scene.get(property_name)
+
+ # exchage with spy
+ scene.set_script(spy)
+ # apply original script properties to the spy
+ for property_name: String in original_properties.keys():
+ scene.set(property_name, original_properties[property_name])
+
+ @warning_ignore("unsafe_method_access")
+ scene.__init()
+ return register_auto_free(scene)
+
+
+static func copy_properties(source :Object, dest :Object) -> void:
+ for property in source.get_property_list():
+ var property_name :String = property["name"]
+ var property_value :Variant = source.get(property_name)
+ if EXCLUDE_PROPERTIES_TO_COPY.has(property_name):
+ continue
+ #if dest.get(property_name) == null:
+ # prints("|%s|" % property_name, source.get(property_name))
+
+ # check for invalid name property
+ if property_name == "name" and property_value == "":
+ dest.set(property_name, "<empty>");
+ continue
+ dest.set(property_name, property_value)
+
+
+static func register_auto_free(obj :Variant) -> Variant:
+ return GdUnitThreadManager.get_current_context().get_execution_context().register_auto_free(obj)
diff --git a/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd.uid b/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd.uid
new file mode 100644
index 0000000..3d7291e
--- /dev/null
+++ b/addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd.uid
@@ -0,0 +1 @@
+uid://b6d5ybx2tm8kb
diff --git a/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd
new file mode 100644
index 0000000..e0bcaf2
--- /dev/null
+++ b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd
@@ -0,0 +1,46 @@
+class_name DoubledSpyClassSourceClassName
+
+const __INSTANCE_ID := "gdunit_doubler_instance_id_{instance_id}"
+
+
+class GdUnitSpyDoublerState:
+ const __SOURCE_CLASS := "{gdunit_source_class}"
+
+ var excluded_methods := PackedStringArray()
+
+ func _init(excluded_methods__ := PackedStringArray()) -> void:
+ excluded_methods = excluded_methods__
+
+
+var __spy_state := GdUnitSpyDoublerState.new()
+@warning_ignore("unused_private_class_variable")
+var __verifier_instance := GdUnitObjectInteractionsVerifier.new()
+
+
+func __init(__excluded_methods := PackedStringArray()) -> void:
+ __init_doubler()
+ __spy_state.excluded_methods = __excluded_methods
+
+
+static func __doubler_state() -> GdUnitSpyDoublerState:
+ if Engine.has_meta(__INSTANCE_ID):
+ return Engine.get_meta(__INSTANCE_ID).__spy_state
+ return null
+
+
+func __init_doubler() -> void:
+ Engine.set_meta(__INSTANCE_ID, self)
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE and Engine.has_meta(__INSTANCE_ID):
+ Engine.remove_meta(__INSTANCE_ID)
+
+
+static func __get_verifier() -> GdUnitObjectInteractionsVerifier:
+ return Engine.get_meta(__INSTANCE_ID).__verifier_instance
+
+
+static func __do_call_real_func(__func_name: String) -> bool:
+ @warning_ignore("unsafe_method_access")
+ return not __doubler_state().excluded_methods.has(__func_name)
diff --git a/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd.uid b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd.uid
new file mode 100644
index 0000000..ed7f83f
--- /dev/null
+++ b/addons/gdUnit4/src/spy/GdUnitSpyImpl.gd.uid
@@ -0,0 +1 @@
+uid://cw054vthlt0xe
diff --git a/addons/gdUnit4/src/ui/GdUnitConsole.gd b/addons/gdUnit4/src/ui/GdUnitConsole.gd
new file mode 100644
index 0000000..eb1e625
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitConsole.gd
@@ -0,0 +1,91 @@
+@tool
+extends Control
+
+const GdUnitUpdateClient = preload("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd")
+const TITLE = "gdUnit4 ${version} Console"
+
+@onready var header := $VBoxContainer/Header
+@onready var title: RichTextLabel = $VBoxContainer/Header/header_title
+@onready var output: RichTextLabel = $VBoxContainer/Console/TextEdit
+
+
+var _test_reporter: GdUnitConsoleTestReporter
+
+
+@warning_ignore("return_value_discarded")
+func _ready() -> void:
+ GdUnitFonts.init_fonts(output)
+ GdUnit4Version.init_version_label(title)
+ GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event)
+ GdUnitSignals.instance().gdunit_message.connect(_on_gdunit_message)
+ GdUnitSignals.instance().gdunit_client_connected.connect(_on_gdunit_client_connected)
+ GdUnitSignals.instance().gdunit_client_disconnected.connect(_on_gdunit_client_disconnected)
+ _test_reporter = GdUnitConsoleTestReporter.new(GdUnitRichTextMessageWriter.new(output))
+
+
+func _notification(what: int) -> void:
+ if what == EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED:
+ _test_reporter.init_colors()
+ if what == NOTIFICATION_PREDELETE:
+ var instance := GdUnitSignals.instance()
+ if instance.gdunit_event.is_connected(_on_gdunit_event):
+ instance.gdunit_event.disconnect(_on_gdunit_event)
+ if instance.gdunit_message.is_connected(_on_gdunit_event):
+ instance.gdunit_message.disconnect(_on_gdunit_message)
+ if instance.gdunit_client_connected.is_connected(_on_gdunit_event):
+ instance.gdunit_client_connected.disconnect(_on_gdunit_client_connected)
+ if instance.gdunit_client_disconnected.is_connected(_on_gdunit_event):
+ instance.gdunit_client_disconnected.disconnect(_on_gdunit_client_disconnected)
+
+
+func setup_update_notification(control: Button) -> void:
+ if not GdUnitSettings.is_update_notification_enabled():
+ _test_reporter.println_message("The search for updates is deactivated.", Color.CORNFLOWER_BLUE)
+ return
+
+ _test_reporter.print_message("Searching for updates... ", Color.CORNFLOWER_BLUE)
+ var update_client := GdUnitUpdateClient.new()
+ add_child(update_client)
+ var response :GdUnitUpdateClient.HttpResponse = await update_client.request_latest_version()
+ if response.status() != 200:
+ _test_reporter.println_message("Information cannot be retrieved from GitHub!", Color.INDIAN_RED)
+ _test_reporter.println_message("Error: %s" % response.response(), Color.INDIAN_RED)
+ return
+ var latest_version := update_client.extract_latest_version(response)
+ if not latest_version.is_greater(GdUnit4Version.current()):
+ _test_reporter.println_message("GdUnit4 is up-to-date.", Color.FOREST_GREEN)
+ return
+
+ _test_reporter.println_message("A new update is available %s" % latest_version, Color.YELLOW)
+ _test_reporter.println_message("Open the GdUnit4 settings and check the update tab.", Color.YELLOW)
+
+ control.icon = GdUnitUiTools.get_icon("Notification", Color.YELLOW)
+ var tween := create_tween()
+ tween.tween_property(control, "self_modulate", Color.VIOLET, .2).set_trans(Tween.TransitionType.TRANS_LINEAR)
+ tween.tween_property(control, "self_modulate", Color.YELLOW, .2).set_trans(Tween.TransitionType.TRANS_BOUNCE)
+ tween.parallel()
+ tween.tween_property(control, "scale", Vector2.ONE*1.05, .4).set_trans(Tween.TransitionType.TRANS_LINEAR)
+ tween.tween_property(control, "scale", Vector2.ONE, .4).set_trans(Tween.TransitionType.TRANS_BOUNCE)
+ tween.set_loops(-1)
+ tween.play()
+
+
+func _on_gdunit_event(event: GdUnitEvent) -> void:
+ match event.type():
+ GdUnitEvent.SESSION_START:
+ _test_reporter.test_session = GdUnitTestSession.new(GdUnitTestDiscoverGuard.instance().get_discovered_tests(), "")
+ GdUnitEvent.SESSION_CLOSE:
+ _test_reporter.test_session = null
+
+
+func _on_gdunit_client_connected(client_id: int) -> void:
+ _test_reporter.clear()
+ _test_reporter.println_message("GdUnit Test Client connected with id: %d" % client_id, Color.hex(0x9887c4))
+
+
+func _on_gdunit_client_disconnected(client_id: int) -> void:
+ _test_reporter.println_message("GdUnit Test Client disconnected with id: %d" % client_id, Color.hex(0x9887c4))
+
+
+func _on_gdunit_message(message: String) -> void:
+ _test_reporter.println_message(message, Color.CORNFLOWER_BLUE)
diff --git a/addons/gdUnit4/src/ui/GdUnitConsole.gd.uid b/addons/gdUnit4/src/ui/GdUnitConsole.gd.uid
new file mode 100644
index 0000000..f3327c6
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitConsole.gd.uid
@@ -0,0 +1 @@
+uid://yk3dmu2vt5w2
diff --git a/addons/gdUnit4/src/ui/GdUnitConsole.tscn b/addons/gdUnit4/src/ui/GdUnitConsole.tscn
new file mode 100644
index 0000000..25f45b5
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitConsole.tscn
@@ -0,0 +1,64 @@
+[gd_scene format=3 uid="uid://dm0wvfyeew7vd"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/GdUnitConsole.gd" id="1"]
+
+[node name="Control" type="Control"]
+use_parent_material = true
+clip_contents = true
+custom_minimum_size = Vector2(0, 200)
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+script = ExtResource("1")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+use_parent_material = true
+clip_contents = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="Header" type="PanelContainer" parent="VBoxContainer"]
+auto_translate_mode = 2
+custom_minimum_size = Vector2(0, 32)
+layout_mode = 2
+localize_numeral_system = false
+mouse_filter = 2
+
+[node name="header_title" type="RichTextLabel" parent="VBoxContainer/Header"]
+auto_translate_mode = 2
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+localize_numeral_system = false
+mouse_filter = 2
+bbcode_enabled = true
+scroll_active = false
+autowrap_mode = 0
+shortcut_keys_enabled = false
+
+[node name="Console" type="ScrollContainer" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="TextEdit" type="RichTextLabel" parent="VBoxContainer/Console"]
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+focus_mode = 2
+bbcode_enabled = true
+scroll_following = true
+context_menu_enabled = true
+selection_enabled = true
diff --git a/addons/gdUnit4/src/ui/GdUnitFonts.gd b/addons/gdUnit4/src/ui/GdUnitFonts.gd
new file mode 100644
index 0000000..0cb0f5d
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitFonts.gd
@@ -0,0 +1,36 @@
+@tool
+class_name GdUnitFonts
+extends RefCounted
+
+
+static func init_fonts(item: CanvasItem) -> float:
+ # set default size
+ item.set("theme_override_font_sizes/font_size", 16)
+
+ if Engine.is_editor_hint():
+ var base_control := EditorInterface.get_base_control()
+ # source modules/mono/editor/GodotTools/GodotTools/Build/BuildOutputView.cs
+ # https://github.com/godotengine/godot/blob/9ee1873ae1e09c217ac24a5800007f63cb895615/editor/editor_log.cpp#L65
+ var output_source_mono := base_control.get_theme_font("output_source_mono", "EditorFonts")
+ var output_source_bold_italic := base_control.get_theme_font("output_source_bold_italic", "EditorFonts")
+ var output_source_italic := base_control.get_theme_font("output_source_italic", "EditorFonts")
+ var output_source_bold := base_control.get_theme_font("output_source_bold", "EditorFonts")
+ var output_source := base_control.get_theme_font("output_source", "EditorFonts")
+ var settings := EditorInterface.get_editor_settings()
+ var scale_factor := EditorInterface.get_editor_scale()
+ var font_size: float = settings.get_setting("interface/editor/main_font_size")
+
+ font_size *= scale_factor
+ item.set("theme_override_fonts/normal_font", output_source)
+ item.set("theme_override_fonts/bold_font", output_source_bold)
+ item.set("theme_override_fonts/italics_font", output_source_italic)
+ item.set("theme_override_fonts/bold_italics_font", output_source_bold_italic)
+ item.set("theme_override_fonts/mono_font", output_source_mono)
+ item.set("theme_override_font_sizes/font_size", font_size)
+ item.set("theme_override_font_sizes/normal_font_size", font_size)
+ item.set("theme_override_font_sizes/bold_font_size", font_size)
+ item.set("theme_override_font_sizes/italics_font_size", font_size)
+ item.set("theme_override_font_sizes/bold_italics_font_size", font_size)
+ item.set("theme_override_font_sizes/mono_font_size", font_size)
+ return font_size
+ return 16.0
diff --git a/addons/gdUnit4/src/ui/GdUnitFonts.gd.uid b/addons/gdUnit4/src/ui/GdUnitFonts.gd.uid
new file mode 100644
index 0000000..f94bc32
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitFonts.gd.uid
@@ -0,0 +1 @@
+uid://cxol0xus2k25t
diff --git a/addons/gdUnit4/src/ui/GdUnitInspector.gd b/addons/gdUnit4/src/ui/GdUnitInspector.gd
new file mode 100644
index 0000000..81d896f
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitInspector.gd
@@ -0,0 +1,34 @@
+@tool
+class_name GdUnitInspecor
+extends Panel
+
+
+var _command_handler := GdUnitCommandHandler.instance()
+var _wait_time := 0.0
+
+
+func _ready() -> void:
+ @warning_ignore("return_value_discarded")
+ GdUnitSignals.instance().gdunit_event.connect(func(event: GdUnitEvent) -> void:
+ if event.type() != GdUnitEvent.SESSION_START:
+ return
+
+ var control: Control = get_parent_control()
+ # if the tab is floating we dont need to set as current
+ if control is TabContainer:
+ var tab_container :TabContainer = control
+ for tab_index in tab_container.get_tab_count():
+ if tab_container.get_tab_title(tab_index) == "GdUnit":
+ tab_container.set_current_tab(tab_index)
+ )
+
+ # propagete the test_counters_changed signal to the progress bar
+ @warning_ignore("unsafe_property_access", "unsafe_method_access")
+ %MainPanel.test_counters_changed.connect(%ProgressBar._on_test_counter_changed)
+
+
+func _process(delta: float) -> void:
+ _wait_time += delta
+ if _wait_time > 5.0:
+ _wait_time = 0
+ _command_handler._do_process()
diff --git a/addons/gdUnit4/src/ui/GdUnitInspector.gd.uid b/addons/gdUnit4/src/ui/GdUnitInspector.gd.uid
new file mode 100644
index 0000000..8f4c2ab
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitInspector.gd.uid
@@ -0,0 +1 @@
+uid://b26rlm46xtlm6
diff --git a/addons/gdUnit4/src/ui/GdUnitInspector.tscn b/addons/gdUnit4/src/ui/GdUnitInspector.tscn
new file mode 100644
index 0000000..cbaf7c9
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitInspector.tscn
@@ -0,0 +1,70 @@
+[gd_scene format=3 uid="uid://mpo5o6d4uybu"]
+
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/InspectorToolBar.tscn" id="1"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/InspectorProgressBar.tscn" id="2"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/InspectorStatusBar.tscn" id="3"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/InspectorMonitor.tscn" id="4"]
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/GdUnitInspector.gd" id="5"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/parts/InspectorTreePanel.tscn" id="7"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/network/GdUnitServer.tscn" id="7_721no"]
+
+[node name="GdUnit" type="Panel"]
+use_parent_material = true
+clip_contents = true
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+size_flags_horizontal = 11
+size_flags_vertical = 3
+focus_mode = 2
+script = ExtResource("5")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+use_parent_material = true
+clip_contents = true
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+size_flags_vertical = 11
+theme_override_constants/separation = 0
+
+[node name="Header" type="VBoxContainer" parent="VBoxContainer"]
+use_parent_material = true
+clip_contents = true
+layout_mode = 2
+size_flags_horizontal = 9
+size_flags_vertical = 0
+
+[node name="ToolBar" parent="VBoxContainer/Header" instance=ExtResource("1")]
+layout_mode = 2
+size_flags_vertical = 1
+
+[node name="ProgressBar" parent="VBoxContainer/Header" instance=ExtResource("2")]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 5
+max_value = 0.0
+
+[node name="StatusBar" parent="VBoxContainer/Header" instance=ExtResource("3")]
+layout_mode = 2
+size_flags_horizontal = 11
+
+[node name="MainPanel" parent="VBoxContainer" instance=ExtResource("7")]
+unique_name_in_owner = true
+layout_mode = 2
+
+[node name="Monitor" parent="VBoxContainer" instance=ExtResource("4")]
+layout_mode = 2
+
+[node name="event_server" parent="." instance=ExtResource("7_721no")]
+
+[connection signal="select_error_next" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_next_item_by_state" binds= [7]]
+[connection signal="select_error_prevous" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_previous_item_by_state" binds= [7]]
+[connection signal="select_failure_next" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_next_item_by_state" binds= [6]]
+[connection signal="select_failure_prevous" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_previous_item_by_state" binds= [6]]
+[connection signal="select_flaky_next" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_next_item_by_state" binds= [5]]
+[connection signal="select_flaky_prevous" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_previous_item_by_state" binds= [5]]
+[connection signal="select_skipped_next" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_next_item_by_state" binds= [2]]
+[connection signal="select_skipped_prevous" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_select_previous_item_by_state" binds= [2]]
+[connection signal="tree_view_mode_changed" from="VBoxContainer/Header/StatusBar" to="VBoxContainer/MainPanel" method="_on_status_bar_tree_view_mode_changed"]
+[connection signal="jump_to_orphan_nodes" from="VBoxContainer/Monitor" to="VBoxContainer/MainPanel" method="select_first_orphan"]
diff --git a/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd
new file mode 100644
index 0000000..8dd0265
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd
@@ -0,0 +1,31 @@
+class_name GdUnitInspectorTreeConstants
+extends RefCounted
+
+
+# the inspector panel presantation
+enum TREE_VIEW_MODE {
+ TREE,
+ FLAT
+}
+
+
+# The inspector sort modes
+enum SORT_MODE {
+ UNSORTED,
+ NAME_ASCENDING,
+ NAME_DESCENDING,
+ EXECUTION_TIME
+}
+
+
+enum STATE {
+ INITIAL,
+ RUNNING,
+ SKIPPED,
+ SUCCESS,
+ WARNING,
+ FLAKY,
+ FAILED,
+ ERROR,
+ ABORDED,
+}
diff --git a/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid
new file mode 100644
index 0000000..463a301
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitInspectorTreeConstants.gd.uid
@@ -0,0 +1 @@
+uid://bfaaukho0a14
diff --git a/addons/gdUnit4/src/ui/GdUnitUiTools.gd b/addons/gdUnit4/src/ui/GdUnitUiTools.gd
new file mode 100644
index 0000000..0bcdb1d
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitUiTools.gd
@@ -0,0 +1,151 @@
+class_name GdUnitUiTools
+extends RefCounted
+
+
+static var _spinner: AnimatedTexture
+
+
+enum ImageFlipMode {
+ HORIZONTAl,
+ VERITCAL
+}
+
+
+## Returns the icon by name, if it exists.
+static func get_icon(icon_name: String, color: = Color.BLACK) -> Texture2D:
+ if not Engine.is_editor_hint():
+ return null
+ var icon := EditorInterface.get_base_control().get_theme_icon(icon_name, "EditorIcons")
+ if icon == null:
+ return null
+ if color != Color.BLACK:
+ icon = _modulate_texture(icon, color)
+ return icon
+
+
+## Returns the icon flipped
+static func get_flipped_icon(icon_name: String, mode: = ImageFlipMode.HORIZONTAl) -> Texture2D:
+ if not Engine.is_editor_hint():
+ return null
+ var icon := EditorInterface.get_base_control().get_theme_icon(icon_name, "EditorIcons")
+ if icon == null:
+ return null
+ return ImageTexture.create_from_image(_flip_image(icon, mode))
+
+
+static func get_spinner() -> AnimatedTexture:
+ if _spinner != null:
+ return _spinner
+ _spinner = AnimatedTexture.new()
+ _spinner.frames = 8
+ _spinner.speed_scale = 2.5
+ for frame in _spinner.frames:
+ _spinner.set_frame_texture(frame, get_icon("Progress%d" % (frame+1)))
+ _spinner.set_frame_duration(frame, 0.2)
+ return _spinner
+
+
+static func get_color_animated_icon(icon_name :String, from :Color, to :Color) -> AnimatedTexture:
+ if not Engine.is_editor_hint():
+ return null
+ var texture := AnimatedTexture.new()
+ texture.frames = 8
+ texture.speed_scale = 2.5
+ var color := from
+ for frame in texture.frames:
+ color = lerp(color, to, .2)
+ texture.set_frame_texture(frame, get_icon(icon_name, color))
+ texture.set_frame_duration(frame, 0.2)
+ return texture
+
+
+static func get_run_overall_icon() -> Texture2D:
+ if not Engine.is_editor_hint():
+ return null
+ var icon := EditorInterface.get_base_control().get_theme_icon("Play", "EditorIcons")
+ var image := _merge_images(icon.get_image(), Vector2i(-2, 0), icon.get_image(), Vector2i(3, 0))
+ return ImageTexture.create_from_image(image)
+
+
+static func get_GDScript_icon(status: String, color: Color) -> Texture2D:
+ if not Engine.is_editor_hint():
+ return null
+ var icon_a := EditorInterface.get_base_control().get_theme_icon("GDScript", "EditorIcons")
+ var icon_b := EditorInterface.get_base_control().get_theme_icon(status, "EditorIcons")
+ var overlay_image := _modulate_image(icon_b.get_image(), color)
+ var image := _merge_images_scaled(icon_a.get_image(), Vector2i(0, 0), overlay_image, Vector2i(5, 5))
+ return ImageTexture.create_from_image(image)
+
+
+static func get_CSharpScript_icon(status: String, color: Color) -> Texture2D:
+ if not Engine.is_editor_hint():
+ return null
+ var icon_a := EditorInterface.get_base_control().get_theme_icon("CSharpScript", "EditorIcons")
+ var icon_b := EditorInterface.get_base_control().get_theme_icon(status, "EditorIcons")
+ var overlay_image := _modulate_image(icon_b.get_image(), color)
+ var image := _merge_images_scaled(icon_a.get_image(), Vector2i(0, 0), overlay_image, Vector2i(5, 5))
+ return ImageTexture.create_from_image(image)
+
+
+static func _modulate_texture(texture: Texture2D, color: Color) -> Texture2D:
+ var image := _modulate_image(texture.get_image(), color)
+ return ImageTexture.create_from_image(image)
+
+
+static func _modulate_image(image: Image, color: Color) -> Image:
+ var data: PackedByteArray = image.data["data"]
+ for pixel in range(0, data.size(), 4):
+ var pixel_a := _to_color(data, pixel)
+ if pixel_a.a8 != 0:
+ pixel_a = pixel_a.lerp(color, .9)
+ data[pixel + 0] = pixel_a.r8
+ data[pixel + 1] = pixel_a.g8
+ data[pixel + 2] = pixel_a.b8
+ data[pixel + 3] = pixel_a.a8
+ var output_image := Image.new()
+ output_image.set_data(image.get_width(), image.get_height(), image.has_mipmaps(), image.get_format(), data)
+ return output_image
+
+
+static func _merge_images(image1: Image, offset1: Vector2i, image2: Image, offset2: Vector2i) -> Image:
+ ## we need to fix the image to have the same size to avoid merge conflicts
+ if image1.get_height() < image2.get_height():
+ image1.resize(image2.get_width(), image2.get_height())
+ # Create a new Image for the merged result
+ var merged_image := Image.create(image1.get_width(), image1.get_height(), false, Image.FORMAT_RGBA8)
+ merged_image.blit_rect_mask(image1, image2, Rect2(Vector2.ZERO, image1.get_size()), offset1)
+ merged_image.blit_rect_mask(image1, image2, Rect2(Vector2.ZERO, image2.get_size()), offset2)
+ return merged_image
+
+
+@warning_ignore("narrowing_conversion")
+static func _merge_images_scaled(image1: Image, offset1: Vector2i, image2: Image, offset2: Vector2i) -> Image:
+ ## we need to fix the image to have the same size to avoid merge conflicts
+ if image1.get_height() < image2.get_height():
+ image1.resize(image2.get_width(), image2.get_height())
+ # Create a new Image for the merged result
+ var merged_image := Image.create(image1.get_width(), image1.get_height(), false, image1.get_format())
+ merged_image.blend_rect(image1, Rect2(Vector2.ZERO, image1.get_size()), offset1)
+ @warning_ignore("narrowing_conversion")
+ image2.resize(image2.get_width()/1.3, image2.get_height()/1.3)
+ merged_image.blend_rect(image2, Rect2(Vector2.ZERO, image2.get_size()), offset2)
+ return merged_image
+
+
+static func _flip_image(texture: Texture2D, mode: ImageFlipMode) -> Image:
+ var flipped_image := Image.new()
+ flipped_image.copy_from(texture.get_image())
+ if mode == ImageFlipMode.VERITCAL:
+ flipped_image.flip_x()
+ else:
+ flipped_image.flip_y()
+ return flipped_image
+
+
+static func _to_color(data: PackedByteArray, position: int) -> Color:
+ var pixel_a := Color()
+ pixel_a.r8 = data[position + 0]
+ pixel_a.g8 = data[position + 1]
+ pixel_a.b8 = data[position + 2]
+ pixel_a.a8 = data[position + 3]
+ return pixel_a
diff --git a/addons/gdUnit4/src/ui/GdUnitUiTools.gd.uid b/addons/gdUnit4/src/ui/GdUnitUiTools.gd.uid
new file mode 100644
index 0000000..4d4f2be
--- /dev/null
+++ b/addons/gdUnit4/src/ui/GdUnitUiTools.gd.uid
@@ -0,0 +1 @@
+uid://cv34ysw4mo25c
diff --git a/addons/gdUnit4/src/ui/ScriptEditorControls.gd b/addons/gdUnit4/src/ui/ScriptEditorControls.gd
new file mode 100644
index 0000000..5e07fe1
--- /dev/null
+++ b/addons/gdUnit4/src/ui/ScriptEditorControls.gd
@@ -0,0 +1,100 @@
+# A tool to provide extended script editor functionallity
+class_name ScriptEditorControls
+extends RefCounted
+
+# https://github.com/godotengine/godot/blob/master/editor/plugins/script_editor_plugin.h
+# the Editor menu popup items
+enum {
+ FILE_NEW,
+ FILE_NEW_TEXTFILE,
+ FILE_OPEN,
+ FILE_REOPEN_CLOSED,
+ FILE_OPEN_RECENT,
+ FILE_SAVE,
+ FILE_SAVE_AS,
+ FILE_SAVE_ALL,
+ FILE_THEME,
+ FILE_RUN,
+ FILE_CLOSE,
+ CLOSE_DOCS,
+ CLOSE_ALL,
+ CLOSE_OTHER_TABS,
+ TOGGLE_SCRIPTS_PANEL,
+ SHOW_IN_FILE_SYSTEM,
+ FILE_COPY_PATH,
+ FILE_TOOL_RELOAD_SOFT,
+ SEARCH_IN_FILES,
+ REPLACE_IN_FILES,
+ SEARCH_HELP,
+ SEARCH_WEBSITE,
+ HELP_SEARCH_FIND,
+ HELP_SEARCH_FIND_NEXT,
+ HELP_SEARCH_FIND_PREVIOUS,
+ WINDOW_MOVE_UP,
+ WINDOW_MOVE_DOWN,
+ WINDOW_NEXT,
+ WINDOW_PREV,
+ WINDOW_SORT,
+ WINDOW_SELECT_BASE = 100
+}
+
+
+# Saves the given script and closes if requested by <close=true>
+# The script is saved when is opened in the editor.
+# The script is closed when <close> is set to true.
+static func save_an_open_script(script_path: String, close:=false) -> bool:
+ #prints("save_an_open_script", script_path, close)
+ if !Engine.is_editor_hint():
+ return false
+ var editor := EditorInterface.get_script_editor()
+ var editor_popup := _menu_popup()
+ # search for the script in all opened editor scrips
+ for open_script in editor.get_open_scripts():
+ if open_script.resource_path == script_path:
+ # select the script in the editor
+ EditorInterface.edit_script(open_script, 0);
+ # save and close
+ editor_popup.id_pressed.emit(FILE_SAVE)
+ if close:
+ editor_popup.id_pressed.emit(FILE_CLOSE)
+ return true
+ return false
+
+
+# Saves all opened script
+static func save_all_open_script() -> void:
+ if Engine.is_editor_hint():
+ _menu_popup().id_pressed.emit(FILE_SAVE_ALL)
+
+
+static func close_open_editor_scripts() -> void:
+ if Engine.is_editor_hint():
+ _menu_popup().id_pressed.emit(CLOSE_ALL)
+
+
+# Edits the given script.
+# The script is openend in the current editor and selected in the file system dock.
+# The line and column on which to open the script can also be specified.
+# The script will be open with the user-configured editor for the script's language which may be an external editor.
+static func edit_script(script_path: String, line_number := -1) -> void:
+ var file_system := EditorInterface.get_resource_filesystem()
+ file_system.update_file(script_path)
+ var file_system_dock := EditorInterface.get_file_system_dock()
+ file_system_dock.navigate_to_path(script_path)
+ EditorInterface.select_file(script_path)
+ var script: GDScript = load(script_path)
+ EditorInterface.edit_script(script, line_number)
+
+
+static func _menu_popup() -> PopupMenu:
+ @warning_ignore("unsafe_method_access")
+ return EditorInterface.get_script_editor().get_child(0).get_child(0).get_child(0).get_popup()
+
+
+static func _print_menu(popup: PopupMenu) -> void:
+ for itemIndex in popup.item_count:
+ prints("get_item_id", popup.get_item_id(itemIndex))
+ prints("get_item_accelerator", popup.get_item_accelerator(itemIndex))
+ prints("get_item_shortcut", popup.get_item_shortcut(itemIndex))
+ prints("get_item_text", popup.get_item_text(itemIndex))
+ prints()
diff --git a/addons/gdUnit4/src/ui/ScriptEditorControls.gd.uid b/addons/gdUnit4/src/ui/ScriptEditorControls.gd.uid
new file mode 100644
index 0000000..d9eeb69
--- /dev/null
+++ b/addons/gdUnit4/src/ui/ScriptEditorControls.gd.uid
@@ -0,0 +1 @@
+uid://bp8qk8bp1l8gm
diff --git a/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd b/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd
new file mode 100644
index 0000000..3849e31
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd
@@ -0,0 +1,45 @@
+class_name GdUnitContextMenuItem
+
+
+var command_id: String:
+ set(value):
+ command_id = value
+ get:
+ return command_id
+
+var name: StringName:
+ set(value):
+ name = value
+ get:
+ return name
+
+var visible: Callable:
+ set(value):
+ visible = value
+ get:
+ return visible
+
+var icon: Texture2D:
+ get:
+ return GdUnitCommandHandler.instance().command_icon(command_id)
+
+
+func _init(p_command_id: String, p_name: StringName, p_is_visible: Callable) -> void:
+ assert(p_command_id != null and not p_command_id.is_empty(), "(%s) missing command id " % p_command_id)
+ assert(p_is_visible != null, "(%s) missing parameter 'GdUnitCommand'" % p_name)
+
+ self.command_id = p_command_id
+ self.name = p_name
+ self.visible = p_is_visible
+
+
+func shortcut() -> Shortcut:
+ return GdUnitCommandHandler.instance().command_shortcut(command_id)
+
+
+func is_visible(...args: Array) -> bool:
+ return visible.callv(args)
+
+
+func execute(...args: Array) -> void:
+ GdUnitCommandHandler.instance().command_execute(command_id, args)
diff --git a/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd.uid b/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd.uid
new file mode 100644
index 0000000..03b589b
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitContextMenuItem.gd.uid
@@ -0,0 +1 @@
+uid://7xcqjry47b4y
diff --git a/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd b/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd
new file mode 100644
index 0000000..0c92b92
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd
@@ -0,0 +1,37 @@
+@tool
+extends EditorContextMenuPlugin
+
+var _context_menus: Array[GdUnitContextMenuItem] = []
+
+
+func _init() -> void:
+ var is_test_suite := func is_visible(script: Script, is_ts: bool) -> bool:
+ if script == null:
+ return false
+ return GdUnitTestSuiteScanner.is_test_suite(script) == is_ts
+ _context_menus.append(GdUnitContextMenuItem.new(
+ GdUnitCommandFileSystemRunTests.ID,
+ "Run Testsuites",
+ is_test_suite.bind(true)
+ ))
+ _context_menus.append(GdUnitContextMenuItem.new(
+ GdUnitCommandFileSystemDebugTests.ID,
+ "Debug Testsuites",
+ is_test_suite.bind(true)
+ ))
+
+ # setup shortcuts
+ for menu_item in _context_menus:
+ if menu_item.shortcut():
+ var cb := func call(...files: Array) -> void:
+ var paths: Array = files[0]
+ menu_item.execute.callv(paths)
+ add_menu_shortcut(menu_item.shortcut(), cb)
+
+
+func _popup_menu(paths: PackedStringArray) -> void:
+ for menu_item in _context_menus:
+ if menu_item.shortcut():
+ add_context_menu_item_from_shortcut(menu_item.name, menu_item.shortcut(), menu_item.icon)
+ else:
+ add_context_menu_item(menu_item.name, menu_item.execute.bindv(paths).unbind(1), menu_item.icon)
diff --git a/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd.uid b/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd.uid
new file mode 100644
index 0000000..fde0854
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd.uid
@@ -0,0 +1 @@
+uid://bgas7gt1plcao
diff --git a/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd
new file mode 100644
index 0000000..2d6071a
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd
@@ -0,0 +1,63 @@
+@tool
+class_name GdUnitInspectorContextMenu
+extends PopupMenu
+
+
+const CONTEXT_MENU_RUN_ID = 0
+const CONTEXT_MENU_DEBUG_ID = 1
+const CONTEXT_MENU_RERUN_UNTIL_ID = 2
+# id 3 is the seperator
+const CONTEXT_MENU_COLLAPSE_ALL = 4
+const CONTEXT_MENU_EXPAND_ALL = 5
+
+
+var command_handler: GdUnitCommandHandler
+
+
+func _ready() -> void:
+ if not Engine.is_editor_hint():
+ return
+ command_handler = GdUnitCommandHandler.instance()
+ _setup_item(CONTEXT_MENU_RUN_ID, "Run Tests", GdUnitCommandInspectorRunTests.ID)
+ _setup_item(CONTEXT_MENU_DEBUG_ID, "Debug Tests", GdUnitCommandInspectorDebugTests.ID)
+ _setup_item(CONTEXT_MENU_RERUN_UNTIL_ID, "Run Tests Until Fail", GdUnitCommandInspectorRerunTestsUntilFailure.ID)
+ _setup_item(CONTEXT_MENU_EXPAND_ALL, "Expand All", GdUnitCommandInspectorTreeExpand.ID)
+ _setup_item(CONTEXT_MENU_COLLAPSE_ALL, "Collapse All", GdUnitCommandInspectorTreeCollapse.ID)
+
+
+func _setup_item(item_id: int, item_name: String, command_id: String) -> void:
+ set_item_text(item_id, item_name)
+ set_item_icon(item_id, command_handler.command_icon(command_id))
+ set_item_shortcut(item_id, command_handler.command_shortcut(command_id))
+
+
+func disable_items() -> void:
+ set_item_disabled(CONTEXT_MENU_RUN_ID, true)
+ set_item_disabled(CONTEXT_MENU_DEBUG_ID, true)
+ set_item_disabled(CONTEXT_MENU_RERUN_UNTIL_ID, true)
+
+
+func enable_items() -> void:
+ set_item_disabled(CONTEXT_MENU_RUN_ID, false)
+ set_item_disabled(CONTEXT_MENU_DEBUG_ID, false)
+ set_item_disabled(CONTEXT_MENU_RERUN_UNTIL_ID, false)
+
+
+func _on_tree_item_mouse_selected(mouse_position: Vector2, mouse_button_index: int, source: Tree) -> void:
+ if mouse_button_index == MOUSE_BUTTON_RIGHT:
+ position = source.get_screen_position() + mouse_position
+ popup()
+
+
+func _on_index_pressed(index: int) -> void:
+ match index:
+ CONTEXT_MENU_RUN_ID:
+ command_handler.command_execute(GdUnitCommandInspectorRunTests.ID)
+ CONTEXT_MENU_DEBUG_ID:
+ command_handler.command_execute(GdUnitCommandInspectorDebugTests.ID)
+ CONTEXT_MENU_RERUN_UNTIL_ID:
+ command_handler.command_execute(GdUnitCommandInspectorRerunTestsUntilFailure.ID)
+ CONTEXT_MENU_EXPAND_ALL:
+ command_handler.command_execute(GdUnitCommandInspectorTreeExpand.ID)
+ CONTEXT_MENU_COLLAPSE_ALL:
+ command_handler.command_execute(GdUnitCommandInspectorTreeCollapse.ID)
diff --git a/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd.uid b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd.uid
new file mode 100644
index 0000000..f1c00de
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd.uid
@@ -0,0 +1 @@
+uid://ch44lt0cpk77q
diff --git a/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.tscn b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.tscn
new file mode 100644
index 0000000..5d87a41
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.tscn
@@ -0,0 +1,70 @@
+[gd_scene format=3 uid="uid://bjcc71p2h1trr"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.gd" id="1_0x0vc"]
+
+[sub_resource type="DPITexture" id="DPITexture_miuuy"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M4 12a1 1 0 0 0 1.555.832l6-4a1 1 0 0 0 0-1.664l-6-4A1 1 0 0 0 4 4z\"/></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_ern2r"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M3 3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm4.975 0A1 1 0 0 0 7 3.998v8a1 1 0 0 0 1.625.781l5-4a1 1 0 0 0 0-1.56l-5-4A1 1 0 0 0 7.975 3z\"/></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_qdci2"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"m8 9.669-3.536 2.583H7v2.537h2v-2.537h2.536zm0-3.314L4.464 3.772H7V1.235h2v2.537h2.536zm-7.296.73h14.591v1.831H.704z\"/></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_hed0i"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"m8 16-3.5-2.5H7V11h2v2.5h2.5zM8 0 4.5 2.5H7V5h2V2.5h2.5zM1 7h14v2H1z\"/></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[node name="contextMenu" type="PopupMenu" unique_id=1824556050]
+auto_translate_mode = 2
+oversampling_override = 1.0
+size = Vector2i(241, 170)
+visible = true
+item_count = 6
+item_0/text = "Run Tests"
+item_0/icon = SubResource("DPITexture_miuuy")
+item_0/id = 0
+item_1/text = "Debug Tests"
+item_1/icon = SubResource("DPITexture_ern2r")
+item_1/id = 1
+item_2/text = "Run Tests Until Fail"
+item_2/icon = SubResource("DPITexture_miuuy")
+item_2/id = 2
+item_3/id = 3
+item_3/separator = true
+item_4/text = "Collapse All"
+item_4/icon = SubResource("DPITexture_qdci2")
+item_4/id = 4
+item_5/text = "Expand All"
+item_5/icon = SubResource("DPITexture_hed0i")
+item_5/id = 5
+script = ExtResource("1_0x0vc")
+
+[connection signal="index_pressed" from="." to="." method="_on_index_pressed"]
diff --git a/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd b/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd
new file mode 100644
index 0000000..26f5612
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd
@@ -0,0 +1,41 @@
+@tool
+extends EditorContextMenuPlugin
+
+
+var _context_menus: Array[GdUnitContextMenuItem] = []
+
+
+func _init() -> void:
+ var is_test_suite := func is_visible(script: Script, is_ts: bool) -> bool:
+ return GdUnitTestSuiteScanner.is_test_suite(script) == is_ts
+ _context_menus.append(GdUnitContextMenuItem.new(
+ GdUnitCommandScriptEditorRunTests.ID,
+ "Run Tests",
+ is_test_suite.bind(true)
+ ))
+ _context_menus.append(GdUnitContextMenuItem.new(
+ GdUnitCommandScriptEditorDebugTests.ID,
+ "Debug Tests",
+ is_test_suite.bind(true)
+ ))
+ _context_menus.append(GdUnitContextMenuItem.new(
+ GdUnitCommandScriptEditorCreateTest.ID,
+ "Create Test",
+ is_test_suite.bind(false)
+ ))
+
+ # setup shortcuts
+ for menu_item in _context_menus:
+ if menu_item.shortcut():
+ add_menu_shortcut(menu_item.shortcut(), menu_item.execute.unbind(1))
+
+
+func _popup_menu(_paths: PackedStringArray) -> void:
+ var current_script := EditorInterface.get_script_editor().get_current_script()
+
+ for menu_item in _context_menus:
+ if menu_item.is_visible(current_script):
+ if menu_item.shortcut():
+ add_context_menu_item_from_shortcut(menu_item.name, menu_item.shortcut())
+ else:
+ add_context_menu_item(menu_item.name, menu_item.execute.unbind(1))
diff --git a/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd.uid b/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd.uid
new file mode 100644
index 0000000..8ab0661
--- /dev/null
+++ b/addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd.uid
@@ -0,0 +1 @@
+uid://f40ddt1acw6h
diff --git a/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd b/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd
new file mode 100644
index 0000000..c36b3ec
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd
@@ -0,0 +1,54 @@
+@tool
+extends PanelContainer
+
+signal jump_to_orphan_nodes()
+
+@onready var ICON_GREEN := GdUnitUiTools.get_icon("Unlinked", Color.WEB_GREEN)
+@onready var ICON_RED := GdUnitUiTools.get_color_animated_icon("Unlinked", Color.YELLOW, Color.ORANGE_RED)
+
+@onready var _button_time: Button = %btn_time
+@onready var _time: Label = %time_value
+@onready var _orphans: Label = %orphan_value
+@onready var _orphan_button: Button = %btn_orphan
+
+var total_elapsed_time := 0
+var total_orphans := 0
+
+
+func _ready() -> void:
+ @warning_ignore("return_value_discarded")
+ GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event)
+ _time.text = ""
+ _orphans.text = "0"
+ _button_time.icon = GdUnitUiTools.get_icon("Time")
+ _orphan_button.icon = ICON_GREEN
+
+
+func status_changed(elapsed_time: int, orphan_nodes: int) -> void:
+ total_elapsed_time += elapsed_time
+ total_orphans += orphan_nodes
+ _time.text = LocalTime.elapsed(total_elapsed_time)
+ _orphans.text = str(total_orphans)
+ if total_orphans > 0:
+ _orphan_button.icon = ICON_RED
+
+
+func _on_gdunit_event(event: GdUnitEvent) -> void:
+ match event.type():
+ GdUnitEvent.INIT:
+ _orphan_button.icon = ICON_GREEN
+ total_elapsed_time = 0
+ total_orphans = 0
+ status_changed(0, 0)
+ GdUnitEvent.TESTCASE_BEFORE:
+ pass
+ GdUnitEvent.TESTCASE_AFTER:
+ status_changed(0, event.orphan_nodes())
+ GdUnitEvent.TESTSUITE_BEFORE:
+ pass
+ GdUnitEvent.TESTSUITE_AFTER:
+ status_changed(event.elapsed_time(), event.orphan_nodes())
+
+
+func _on_ToolButton_pressed() -> void:
+ jump_to_orphan_nodes.emit()
diff --git a/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd.uid
new file mode 100644
index 0000000..ea72742
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorMonitor.gd.uid
@@ -0,0 +1 @@
+uid://buiiqjcucwy8g
diff --git a/addons/gdUnit4/src/ui/parts/InspectorMonitor.tscn b/addons/gdUnit4/src/ui/parts/InspectorMonitor.tscn
new file mode 100644
index 0000000..0262b8c
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorMonitor.tscn
@@ -0,0 +1,94 @@
+[gd_scene load_steps=6 format=3 uid="uid://djp8ait0bxpsc"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/InspectorMonitor.gd" id="3"]
+
+[sub_resource type="Image" id="Image_sx31i"]
+data = {
+"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 4, 227, 227, 227, 36, 227, 227, 227, 36, 255, 255, 255, 4, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 131, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 131, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 5, 225, 225, 225, 76, 224, 224, 224, 255, 224, 224, 224, 255, 226, 226, 226, 77, 255, 255, 255, 5, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 99, 224, 224, 224, 232, 224, 224, 224, 244, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 244, 224, 224, 224, 233, 224, 224, 224, 97, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 225, 225, 225, 135, 224, 224, 224, 247, 224, 224, 224, 115, 234, 234, 234, 12, 224, 224, 224, 130, 224, 224, 224, 130, 234, 234, 234, 12, 225, 225, 225, 116, 224, 224, 224, 248, 224, 224, 224, 132, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 226, 226, 226, 77, 224, 224, 224, 251, 224, 224, 224, 64, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 66, 224, 224, 224, 252, 225, 225, 225, 75, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 201, 224, 224, 224, 146, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 2, 224, 224, 224, 146, 224, 224, 224, 106, 255, 255, 255, 0, 225, 225, 225, 150, 224, 224, 224, 195, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 24, 224, 224, 224, 255, 226, 226, 226, 43, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 233, 233, 233, 23, 225, 225, 225, 166, 224, 224, 224, 237, 228, 228, 228, 47, 255, 255, 255, 0, 225, 225, 225, 51, 224, 224, 224, 255, 224, 224, 224, 16, 255, 255, 255, 0, 255, 255, 255, 0, 225, 225, 225, 67, 224, 224, 224, 255, 225, 225, 225, 215, 227, 227, 227, 9, 255, 255, 255, 0, 255, 255, 255, 0, 223, 223, 223, 239, 224, 224, 224, 253, 224, 224, 224, 49, 255, 255, 255, 0, 230, 230, 230, 30, 224, 224, 224, 230, 224, 224, 224, 255, 224, 224, 224, 49, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 41, 224, 224, 224, 255, 225, 225, 225, 101, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 139, 224, 224, 224, 139, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 5, 225, 225, 225, 117, 224, 224, 224, 255, 224, 224, 224, 33, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 6, 224, 224, 224, 240, 226, 226, 226, 87, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 96, 224, 224, 224, 236, 255, 255, 255, 3, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 225, 225, 225, 143, 224, 224, 224, 211, 224, 224, 224, 8, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 232, 232, 232, 11, 224, 224, 224, 216, 225, 225, 225, 141, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 238, 238, 238, 15, 224, 224, 224, 220, 224, 224, 224, 178, 238, 238, 238, 15, 255, 255, 255, 0, 225, 225, 225, 51, 225, 225, 225, 51, 255, 255, 255, 0, 227, 227, 227, 18, 224, 224, 224, 184, 224, 224, 224, 218, 238, 238, 238, 15, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 227, 227, 227, 36, 224, 224, 224, 212, 224, 224, 224, 232, 225, 225, 225, 133, 224, 224, 224, 251, 224, 224, 224, 240, 225, 225, 225, 135, 224, 224, 224, 234, 224, 224, 224, 208, 225, 225, 225, 34, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 230, 230, 230, 10, 224, 224, 224, 107, 224, 224, 224, 197, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 196, 224, 224, 224, 104, 224, 224, 224, 8, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_ugpqy"]
+image = SubResource("Image_sx31i")
+
+[sub_resource type="Image" id="Image_gkq5u"]
+data = {
+"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 251, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 234, 22, 138, 22, 247, 22, 138, 22, 253, 22, 138, 22, 253, 22, 138, 22, 247, 22, 138, 22, 233, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 251, 22, 138, 22, 236, 255, 255, 255, 0, 22, 138, 22, 255, 255, 255, 255, 0, 23, 138, 23, 233, 22, 138, 22, 254, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 253, 23, 138, 23, 233, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 236, 22, 138, 22, 253, 22, 138, 22, 236, 22, 138, 22, 251, 255, 255, 255, 0, 22, 138, 22, 247, 22, 138, 22, 255, 22, 138, 22, 248, 22, 138, 22, 233, 23, 138, 23, 233, 22, 138, 22, 249, 22, 138, 22, 255, 22, 138, 22, 246, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 236, 22, 138, 22, 251, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 249, 22, 138, 22, 253, 23, 138, 23, 232, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 234, 22, 138, 22, 255, 22, 138, 22, 253, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 251, 22, 138, 22, 255, 22, 138, 22, 251, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 24, 139, 24, 231, 23, 138, 23, 231, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 234, 22, 138, 22, 255, 22, 138, 22, 253, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 231, 23, 138, 23, 234, 22, 138, 22, 249, 22, 138, 22, 255, 22, 138, 22, 246, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 233, 22, 138, 22, 247, 22, 138, 22, 249, 24, 139, 24, 231, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 245, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 253, 23, 138, 23, 233, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 234, 22, 138, 22, 254, 22, 138, 22, 255, 22, 138, 22, 253, 23, 138, 23, 231, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 241, 22, 138, 22, 253, 22, 138, 22, 253, 22, 138, 22, 246, 22, 138, 22, 233, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 247, 22, 138, 22, 255, 22, 138, 22, 248, 23, 138, 23, 232, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 245, 23, 138, 23, 241, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 253, 22, 138, 22, 255, 22, 138, 22, 233, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 231, 22, 138, 22, 255, 22, 138, 22, 253, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 253, 22, 138, 22, 255, 23, 138, 23, 233, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 234, 22, 138, 22, 255, 22, 138, 22, 253, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 247, 22, 138, 22, 255, 22, 138, 22, 249, 22, 138, 22, 234, 23, 138, 23, 234, 22, 138, 22, 249, 22, 138, 22, 255, 22, 138, 22, 246, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 22, 138, 22, 233, 22, 138, 22, 253, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 255, 22, 138, 22, 253, 22, 138, 22, 233, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 23, 138, 23, 233, 22, 138, 22, 246, 22, 138, 22, 253, 22, 138, 22, 253, 22, 138, 22, 246, 23, 138, 23, 233, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_nj5du"]
+image = SubResource("Image_gkq5u")
+
+[node name="Monitor" type="PanelContainer"]
+clip_contents = true
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_right = -793.0
+offset_bottom = -564.0
+size_flags_horizontal = 9
+size_flags_vertical = 9
+script = ExtResource("3")
+
+[node name="HBoxContainer" type="HBoxContainer" parent="."]
+layout_mode = 2
+size_flags_vertical = 4
+
+[node name="timer" type="HBoxContainer" parent="HBoxContainer"]
+layout_mode = 2
+
+[node name="btn_time" type="Button" parent="HBoxContainer/timer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 4
+auto_translate = false
+localize_numeral_system = false
+tooltip_text = "Shows the total elapsed time of test execution."
+mouse_force_pass_scroll_events = false
+button_mask = 0
+shortcut_feedback = false
+shortcut_in_tooltip = false
+text = "Time"
+icon = SubResource("ImageTexture_ugpqy")
+flat = true
+
+[node name="time_value" type="Label" parent="HBoxContainer/timer"]
+unique_name_in_owner = true
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 3
+auto_translate = false
+localize_numeral_system = false
+max_lines_visible = 1
+
+[node name="orphan" type="HBoxContainer" parent="HBoxContainer/timer"]
+layout_mode = 2
+
+[node name="btn_orphan" type="Button" parent="HBoxContainer/timer/orphan"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 4
+auto_translate = false
+localize_numeral_system = false
+tooltip_text = "Shows the total orphan nodes detected."
+text = "Orphans"
+icon = SubResource("ImageTexture_nj5du")
+
+[node name="orphan_value" type="Label" parent="HBoxContainer/timer/orphan"]
+unique_name_in_owner = true
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 3
+auto_translate = false
+localize_numeral_system = false
+text = "0"
+max_lines_visible = 1
diff --git a/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd
new file mode 100644
index 0000000..d368ed3
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd
@@ -0,0 +1,49 @@
+@tool
+extends ProgressBar
+
+
+@onready var status: Label = $Label
+@onready var style: StyleBoxFlat = get("theme_override_styles/fill")
+
+var _state: GdUnitInspectorTreeConstants.STATE
+
+func _ready() -> void:
+ style.bg_color = Color.DARK_GREEN
+ value = 0
+ max_value = 0
+ update_text()
+
+
+func update_text() -> void:
+ status.text = "%d:%d" % [value, max_value]
+
+
+func _on_test_counter_changed(index: int, total: int, state: GdUnitInspectorTreeConstants.STATE) -> void:
+ value = index
+ max_value = total
+ update_text()
+
+ # inital state
+ if index == 0:
+ style.bg_color = Color.DARK_GREEN
+
+ # do only update the state is higher prio than current state
+ if state <= _state:
+ return
+ _state = state
+
+ if is_flaky(state):
+ style.bg_color = Color.WEB_GREEN
+ if is_failed(state):
+ style.bg_color = Color.DARK_RED
+
+
+func is_failed(state: GdUnitInspectorTreeConstants.STATE) -> bool:
+ return state in [
+ GdUnitInspectorTreeConstants.STATE.FAILED,
+ GdUnitInspectorTreeConstants.STATE.ERROR,
+ GdUnitInspectorTreeConstants.STATE.ABORDED]
+
+
+func is_flaky(state: GdUnitInspectorTreeConstants.STATE) -> bool:
+ return state == GdUnitInspectorTreeConstants.STATE.FLAKY
diff --git a/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd.uid
new file mode 100644
index 0000000..61df203
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd.uid
@@ -0,0 +1 @@
+uid://de8bjgxoj10u3
diff --git a/addons/gdUnit4/src/ui/parts/InspectorProgressBar.tscn b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.tscn
new file mode 100644
index 0000000..1824230
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorProgressBar.tscn
@@ -0,0 +1,33 @@
+[gd_scene load_steps=3 format=3 uid="uid://dva3tonxsxrlk"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/InspectorProgressBar.gd" id="1"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ayfir"]
+bg_color = Color(0, 0.392157, 0, 1)
+
+[node name="ProgressBar" type="ProgressBar"]
+custom_minimum_size = Vector2(0, 20)
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_vertical = 9
+theme_override_styles/fill = SubResource("StyleBoxFlat_ayfir")
+rounded = true
+allow_greater = true
+show_percentage = false
+script = ExtResource("1")
+
+[node name="Label" type="Label" parent="."]
+use_parent_material = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_vertical = 3
+horizontal_alignment = 1
+vertical_alignment = 1
+max_lines_visible = 1
diff --git a/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd
new file mode 100644
index 0000000..1863459
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd
@@ -0,0 +1,209 @@
+@tool
+extends PanelContainer
+
+signal select_failure_next()
+signal select_failure_prevous()
+signal select_error_next()
+signal select_error_prevous()
+signal select_flaky_next()
+signal select_flaky_prevous()
+signal select_skipped_next()
+signal select_skipped_prevous()
+
+@warning_ignore("unused_signal")
+signal tree_view_mode_changed(flat :bool)
+
+@onready var _errors: Label = %error_value
+@onready var _failures: Label = %failure_value
+@onready var _flaky_value: Label = %flaky_value
+@onready var _skipped_value: Label = %skipped_value
+#@onready var _button_failure_up: Button = %btn_failure_up
+#@onready var _button_failure_down: Button = %btn_failure_down
+@onready var _button_sync: Button = %btn_tree_sync
+@onready var _button_view_mode: MenuButton = %btn_tree_mode
+@onready var _button_sort_mode: MenuButton = %btn_tree_sort
+
+@onready var _icon_errors: TextureRect = %icon_errors
+@onready var _icon_failures: TextureRect = %icon_failures
+@onready var _icon_flaky: TextureRect = %icon_flaky
+@onready var _icon_skipped: TextureRect = %icon_skipped
+
+var total_failed := 0
+var total_errors := 0
+var total_flaky := 0
+var total_skipped := 0
+
+
+var icon_mappings := {
+ # tree sort modes
+ 0x100 + GdUnitInspectorTreeConstants.SORT_MODE.UNSORTED : GdUnitUiTools.get_icon("TripleBar"),
+ 0x100 + GdUnitInspectorTreeConstants.SORT_MODE.NAME_ASCENDING : GdUnitUiTools.get_icon("Sort"),
+ 0x100 + GdUnitInspectorTreeConstants.SORT_MODE.NAME_DESCENDING : GdUnitUiTools.get_flipped_icon("Sort"),
+ 0x100 + GdUnitInspectorTreeConstants.SORT_MODE.EXECUTION_TIME : GdUnitUiTools.get_icon("History"),
+ # tree view modes
+ 0x200 + GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE : GdUnitUiTools.get_icon("Tree", Color.GHOST_WHITE),
+ 0x200 + GdUnitInspectorTreeConstants.TREE_VIEW_MODE.FLAT : GdUnitUiTools.get_icon("AnimationTrackGroup", Color.GHOST_WHITE)
+}
+
+
+@warning_ignore("return_value_discarded")
+func _ready() -> void:
+ _failures.text = "0"
+ _errors.text = "0"
+ _flaky_value.text = "0"
+ _skipped_value.text = "0"
+ _icon_failures.texture = GdUnitUiTools.get_icon("StatusError", Color.SKY_BLUE)
+ _icon_errors.texture = GdUnitUiTools.get_icon("StatusError", Color.DARK_RED)
+ _icon_flaky.texture = GdUnitUiTools.get_icon("CheckBox", Color.GREEN_YELLOW)
+ _icon_skipped.texture = GdUnitUiTools.get_icon("CheckBox", Color.WEB_GRAY)
+
+ #_button_failure_up.icon = GdUnitUiTools.get_icon("ArrowUp")
+ #_button_failure_down.icon = GdUnitUiTools.get_icon("ArrowDown")
+ _button_sync.icon = GdUnitUiTools.get_icon("Loop")
+ _set_sort_mode_menu_options()
+ _set_view_mode_menu_options()
+ GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event)
+ GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed)
+
+
+func _set_sort_mode_menu_options() -> void:
+ _button_sort_mode.icon = GdUnitUiTools.get_icon("Sort")
+ # construct context sort menu according to the available modes
+ var context_menu :PopupMenu = _button_sort_mode.get_popup()
+ context_menu.clear()
+
+ if not context_menu.index_pressed.is_connected(_on_sort_mode_changed):
+ @warning_ignore("return_value_discarded")
+ context_menu.index_pressed.connect(_on_sort_mode_changed)
+
+ var configured_sort_mode := GdUnitSettings.get_inspector_tree_sort_mode()
+ for sort_mode: String in GdUnitInspectorTreeConstants.SORT_MODE.keys():
+ var enum_value :int = GdUnitInspectorTreeConstants.SORT_MODE.get(sort_mode)
+ var icon :Texture2D = icon_mappings[0x100 + enum_value]
+ context_menu.add_icon_check_item(icon, normalise(sort_mode), enum_value)
+ context_menu.set_item_checked(enum_value, configured_sort_mode == enum_value)
+
+
+func _set_view_mode_menu_options() -> void:
+ _button_view_mode.icon = GdUnitUiTools.get_icon("Tree", Color.GHOST_WHITE)
+ # construct context tree view menu according to the available modes
+ var context_menu :PopupMenu = _button_view_mode.get_popup()
+ context_menu.clear()
+
+ if not context_menu.index_pressed.is_connected(_on_tree_view_mode_changed):
+ @warning_ignore("return_value_discarded")
+ context_menu.index_pressed.connect(_on_tree_view_mode_changed)
+
+ var configured_tree_view_mode := GdUnitSettings.get_inspector_tree_view_mode()
+ for tree_view_mode: String in GdUnitInspectorTreeConstants.TREE_VIEW_MODE.keys():
+ var enum_value :int = GdUnitInspectorTreeConstants.TREE_VIEW_MODE.get(tree_view_mode)
+ var icon :Texture2D = icon_mappings[0x200 + enum_value]
+ context_menu.add_icon_check_item(icon, normalise(tree_view_mode), enum_value)
+ context_menu.set_item_checked(enum_value, configured_tree_view_mode == enum_value)
+
+
+func normalise(value: String) -> String:
+ var parts := value.to_lower().split("_")
+ parts[0] = parts[0].capitalize()
+ return " ".join(parts)
+
+
+func status_changed(errors: int, failed: int, flaky: int, skipped: int) -> void:
+ total_failed += failed
+ total_errors += errors
+ total_flaky += flaky
+ total_skipped += skipped
+ _failures.text = str(total_failed)
+ _errors.text = str(total_errors)
+ _flaky_value.text = str(total_flaky)
+ _skipped_value.text = str(total_skipped)
+
+
+func disable_buttons(value :bool) -> void:
+ _button_sync.set_disabled(value)
+ _button_sort_mode.set_disabled(value)
+ _button_view_mode.set_disabled(value)
+
+
+func _on_gdunit_event(event: GdUnitEvent) -> void:
+ match event.type():
+ GdUnitEvent.DISCOVER_START:
+ disable_buttons(true)
+
+ GdUnitEvent.DISCOVER_END:
+ disable_buttons(false)
+
+ GdUnitEvent.INIT:
+ total_errors = 0
+ total_failed = 0
+ total_flaky = 0
+ total_skipped = 0
+ status_changed(total_errors, total_failed, total_flaky, total_skipped)
+
+ GdUnitEvent.TESTCASE_AFTER:
+ status_changed(event.error_count(), event.failed_count(), event.is_flaky(), event.is_skipped())
+
+ GdUnitEvent.TESTSUITE_AFTER:
+ status_changed(event.error_count(), event.failed_count(), event.is_flaky(), 0)
+
+ GdUnitEvent.SESSION_START:
+ disable_buttons(true)
+
+ GdUnitEvent.SESSION_CLOSE:
+ disable_buttons(false)
+
+
+func _on_btn_error_up_pressed() -> void:
+ select_error_prevous.emit()
+
+
+func _on_btn_error_down_pressed() -> void:
+ select_error_next.emit()
+
+
+func _on_failure_up_pressed() -> void:
+ select_failure_prevous.emit()
+
+
+func _on_failure_down_pressed() -> void:
+ select_failure_next.emit()
+
+
+func _on_btn_flaky_up_pressed() -> void:
+ select_flaky_prevous.emit()
+
+
+func _on_btn_flaky_down_pressed() -> void:
+ select_flaky_next.emit()
+
+
+func _on_btn_skipped_up_pressed() -> void:
+ select_skipped_prevous.emit()
+
+
+func _on_btn_skipped_down_pressed() -> void:
+ select_skipped_next.emit()
+
+
+func _on_btn_tree_sync_pressed() -> void:
+ await GdUnitTestDiscoverer.run()
+
+
+func _on_sort_mode_changed(index: int) -> void:
+ var selected_sort_mode :GdUnitInspectorTreeConstants.SORT_MODE = GdUnitInspectorTreeConstants.SORT_MODE.values()[index]
+ GdUnitSettings.set_inspector_tree_sort_mode(selected_sort_mode)
+
+
+func _on_tree_view_mode_changed(index: int) ->void:
+ var selected_tree_mode :GdUnitInspectorTreeConstants.TREE_VIEW_MODE = GdUnitInspectorTreeConstants.TREE_VIEW_MODE.values()[index]
+ GdUnitSettings.set_inspector_tree_view_mode(selected_tree_mode)
+
+
+################################################################################
+# external signal receiver
+################################################################################
+func _on_settings_changed(property :GdUnitProperty) -> void:
+ if property.name() == GdUnitSettings.INSPECTOR_TREE_SORT_MODE:
+ _set_sort_mode_menu_options()
+ if property.name() == GdUnitSettings.INSPECTOR_TREE_VIEW_MODE:
+ _set_view_mode_menu_options()
diff --git a/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd.uid
new file mode 100644
index 0000000..e4c2c25
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd.uid
@@ -0,0 +1 @@
+uid://c0whlxhcst61i
diff --git a/addons/gdUnit4/src/ui/parts/InspectorStatusBar.tscn b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.tscn
new file mode 100644
index 0000000..d4ed816
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorStatusBar.tscn
@@ -0,0 +1,465 @@
+[gd_scene load_steps=26 format=3 uid="uid://c22l4odk7qesc"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/InspectorStatusBar.gd" id="3"]
+
+[sub_resource type="DPITexture" id="DPITexture_mb3ih"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M8 1v2H6a5 5 0 0 0-4 8l1.414-1.414A3 3 0 0 1 6 5h2v2l4-3-4-3zm6 4-1.414 1.414A3 3 0 0 1 10 11H8V9l-4 3 4 3v-2h2a5 5 0 0 0 4-8z\"/></svg>
+"
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_wo03e"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M9 1v2h6V1zM4 1a1 1 0 0 0-.691.291l-2 2a1 1 0 0 0 1.414 1.414l.293-.293v7.172l-.293-.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l2-2a1 1 0 0 0-1.414-1.414l-.293.293V4.412l.293.293a1 1 0 0 0 1.414-1.414l-2-2A1 1 0 0 0 4 1zm5 6v2h4V7zm0 6v2h2v-2z\"/></svg>
+"
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_ixycx"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"none\" stroke=\"#e0e0e0\" d=\"M2 4h12zm0 4h12zm0 4h12z\"/></svg>
+"
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="Image" id="Image_c80wp"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 231, 231, 231, 21, 224, 224, 224, 194, 224, 224, 224, 196, 232, 232, 232, 22, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 212, 232, 232, 232, 22, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 194, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 197, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 176, 224, 224, 224, 200, 224, 224, 224, 253, 224, 224, 224, 255, 225, 225, 225, 199, 224, 224, 224, 179, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 252, 224, 224, 224, 255, 255, 255, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 252, 224, 224, 224, 255, 255, 255, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 252, 224, 224, 224, 255, 255, 255, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 252, 224, 224, 224, 255, 255, 255, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 252, 224, 224, 224, 255, 255, 255, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 252, 224, 224, 224, 255, 255, 255, 255, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 171, 224, 224, 224, 195, 224, 224, 224, 253, 224, 224, 224, 255, 224, 224, 224, 195, 225, 225, 225, 175, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 196, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 225, 225, 225, 199, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 233, 233, 233, 23, 224, 224, 224, 213, 224, 224, 224, 255, 224, 224, 224, 255, 225, 225, 225, 215, 224, 224, 224, 24, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 233, 233, 233, 23, 224, 224, 224, 198, 224, 224, 224, 201, 224, 224, 224, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_eis20"]
+image = SubResource("Image_c80wp")
+
+[sub_resource type="DPITexture" id="DPITexture_t2qd7"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M5 8a4 4 0 1 1 4 4v2a6 6 0 1 0-6-6H1l3 4 3-4zm3-3h2v2h2v2H8z\"/></svg>
+"
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="Image" id="Image_jh28t"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 248, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 248, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_1mh1t"]
+image = SubResource("Image_jh28t")
+
+[sub_resource type="Image" id="Image_lpjla"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 248, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 237, 247, 245, 248, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 237, 247, 245, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_bq8kn"]
+image = SubResource("Image_lpjla")
+
+[sub_resource type="Image" id="Image_bwbka"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 249, 249, 255, 230, 246, 246, 252, 230, 249, 249, 255, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 246, 252, 237, 246, 246, 252, 255, 246, 246, 252, 248, 0, 0, 0, 0, 246, 246, 252, 254, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 246, 252, 236, 246, 246, 252, 254, 246, 246, 252, 247, 0, 0, 0, 0, 246, 246, 252, 254, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 246, 253, 231, 246, 246, 253, 232, 246, 246, 252, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 246, 252, 243, 246, 246, 252, 255, 246, 246, 252, 242, 246, 246, 252, 230, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 246, 252, 242, 246, 246, 252, 253, 246, 246, 252, 241, 246, 246, 252, 230, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 246, 252, 244, 246, 246, 252, 255, 246, 246, 252, 241, 246, 246, 252, 230, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 246, 246, 252, 244, 246, 246, 252, 255, 246, 246, 252, 241, 246, 246, 252, 230, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 246, 246, 252, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_8lbfl"]
+image = SubResource("Image_bwbka")
+
+[sub_resource type="Image" id="Image_ki3oo"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 232, 151, 12, 11, 242, 151, 12, 11, 250, 151, 12, 11, 254, 151, 12, 11, 254, 151, 12, 11, 250, 151, 12, 11, 242, 151, 12, 10, 232, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 10, 238, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 11, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 237, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 10, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 10, 232, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 240, 151, 12, 10, 234, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 11, 234, 151, 12, 11, 241, 151, 12, 11, 255, 151, 12, 11, 253, 151, 11, 10, 232, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 242, 151, 12, 11, 255, 151, 12, 11, 254, 151, 12, 10, 234, 0, 0, 0, 0, 151, 12, 10, 234, 151, 12, 11, 253, 151, 12, 11, 253, 151, 12, 11, 234, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 241, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 250, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 10, 234, 0, 0, 0, 0, 151, 12, 10, 234, 151, 12, 10, 234, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 250, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 10, 234, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 10, 234, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 10, 234, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 250, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 11, 234, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 10, 234, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 250, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 242, 151, 12, 11, 255, 151, 12, 11, 254, 151, 12, 11, 234, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 11, 253, 151, 12, 11, 253, 151, 12, 11, 234, 0, 0, 0, 0, 151, 12, 11, 234, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 10, 241, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 10, 232, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 241, 151, 12, 11, 234, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 11, 234, 151, 12, 11, 241, 151, 12, 11, 255, 151, 12, 11, 253, 151, 11, 10, 231, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 237, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 254, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 237, 151, 12, 11, 253, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 255, 151, 12, 11, 253, 151, 12, 11, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 151, 12, 11, 232, 151, 12, 11, 242, 151, 12, 11, 250, 151, 12, 11, 253, 151, 12, 11, 253, 151, 12, 11, 250, 151, 12, 11, 241, 151, 11, 10, 232, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_ivm1h"]
+image = SubResource("Image_ki3oo")
+
+[sub_resource type="Image" id="Image_uqb0l"]
+data = {
+"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 255, 224, 224, 224, 255, 231, 231, 231, 21, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 211, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 210, 231, 231, 231, 21, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 211, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 210, 231, 231, 231, 21, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 211, 224, 224, 224, 255, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 210, 231, 231, 231, 21, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 195, 224, 224, 224, 255, 224, 224, 224, 210, 230, 230, 230, 20, 224, 224, 224, 255, 224, 224, 224, 255, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 194, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 178, 224, 224, 224, 194, 230, 230, 230, 20, 255, 255, 255, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 194, 224, 224, 224, 179, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 180, 224, 224, 224, 180, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_1oriu"]
+image = SubResource("Image_uqb0l")
+
+[sub_resource type="Image" id="Image_j00vj"]
+data = {
+"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 181, 224, 224, 224, 180, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 180, 224, 224, 224, 195, 231, 231, 231, 21, 255, 255, 255, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 195, 224, 224, 224, 178, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 224, 224, 224, 195, 224, 224, 224, 255, 224, 224, 224, 210, 231, 231, 231, 21, 224, 224, 224, 255, 224, 224, 224, 255, 231, 231, 231, 21, 224, 224, 224, 211, 224, 224, 224, 255, 224, 224, 224, 194, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 211, 224, 224, 224, 255, 224, 224, 224, 210, 230, 230, 230, 20, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 210, 230, 230, 230, 20, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 210, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 210, 230, 230, 230, 20, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 231, 231, 231, 21, 224, 224, 224, 255, 224, 224, 224, 255, 230, 230, 230, 20, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_ikyhk"]
+image = SubResource("Image_j00vj")
+
+[sub_resource type="Image" id="Image_0oden"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 198, 223, 232, 147, 197, 222, 242, 147, 197, 222, 250, 147, 197, 222, 254, 147, 197, 222, 254, 147, 197, 222, 250, 147, 197, 222, 242, 147, 197, 222, 232, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 238, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 197, 222, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 198, 222, 237, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 232, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 240, 147, 198, 222, 234, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 198, 222, 234, 147, 197, 222, 241, 147, 197, 222, 255, 147, 197, 222, 253, 147, 197, 222, 232, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 242, 147, 197, 222, 255, 147, 197, 222, 254, 147, 198, 222, 234, 0, 0, 0, 0, 147, 198, 222, 234, 147, 197, 222, 253, 147, 197, 222, 253, 147, 197, 222, 234, 0, 0, 0, 0, 147, 197, 222, 234, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 241, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 250, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 198, 222, 234, 0, 0, 0, 0, 147, 198, 222, 234, 147, 198, 222, 234, 0, 0, 0, 0, 147, 197, 222, 234, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 250, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 198, 222, 234, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 234, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 198, 222, 234, 0, 0, 0, 0, 0, 0, 0, 0, 147, 198, 222, 234, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 250, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 197, 222, 234, 0, 0, 0, 0, 147, 197, 222, 234, 147, 198, 222, 234, 0, 0, 0, 0, 147, 197, 222, 234, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 250, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 242, 147, 197, 222, 255, 147, 197, 222, 254, 147, 198, 222, 234, 0, 0, 0, 0, 147, 197, 222, 234, 147, 197, 222, 253, 147, 197, 222, 253, 147, 197, 222, 234, 0, 0, 0, 0, 147, 197, 222, 234, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 241, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 232, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 241, 147, 197, 222, 234, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 197, 222, 234, 147, 197, 222, 241, 147, 197, 222, 255, 147, 197, 222, 253, 147, 197, 221, 231, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 198, 222, 237, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 254, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 197, 222, 237, 147, 197, 222, 253, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 255, 147, 197, 222, 253, 147, 197, 222, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 198, 222, 232, 147, 197, 222, 242, 147, 197, 222, 250, 147, 197, 222, 253, 147, 197, 222, 253, 147, 197, 222, 250, 147, 197, 222, 241, 147, 197, 222, 232, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_suo5c"]
+image = SubResource("Image_0oden")
+
+[sub_resource type="Image" id="Image_ipq44"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 237, 170, 253, 57, 252, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 254, 58, 242, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 252, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 254, 58, 242, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 58, 234, 170, 253, 57, 247, 171, 255, 57, 231, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 58, 234, 170, 253, 57, 253, 170, 253, 57, 255, 170, 254, 57, 243, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 58, 234, 170, 253, 57, 253, 170, 253, 57, 255, 170, 254, 57, 247, 171, 255, 58, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 170, 254, 58, 232, 170, 254, 57, 232, 0, 0, 0, 0, 170, 253, 58, 234, 170, 253, 57, 253, 170, 253, 57, 255, 170, 254, 57, 247, 171, 255, 58, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 170, 254, 58, 232, 170, 253, 57, 251, 170, 253, 57, 251, 170, 254, 58, 236, 170, 253, 57, 253, 170, 253, 57, 255, 170, 254, 57, 247, 171, 255, 58, 230, 0, 0, 0, 0, 170, 254, 58, 242, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 170, 254, 57, 232, 170, 253, 57, 251, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 254, 57, 247, 171, 255, 58, 230, 0, 0, 0, 0, 170, 254, 58, 242, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 170, 254, 57, 232, 170, 253, 57, 251, 170, 253, 57, 255, 170, 254, 57, 247, 171, 255, 58, 230, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 254, 57, 232, 170, 253, 57, 244, 171, 255, 58, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 255, 170, 253, 57, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 253, 57, 252, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 252, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 170, 254, 57, 237, 170, 253, 57, 252, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 255, 170, 253, 57, 252, 170, 254, 57, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_d5kq4"]
+image = SubResource("Image_ipq44")
+
+[sub_resource type="Image" id="Image_8d0da"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 237, 129, 139, 130, 252, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 242, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 252, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 242, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 131, 234, 129, 139, 130, 247, 130, 141, 130, 231, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 131, 234, 129, 139, 130, 253, 129, 139, 130, 255, 129, 139, 130, 243, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 131, 234, 129, 139, 130, 253, 129, 139, 130, 255, 129, 139, 130, 247, 131, 141, 131, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 130, 140, 131, 232, 129, 140, 130, 232, 0, 0, 0, 0, 129, 139, 131, 234, 129, 139, 130, 253, 129, 139, 130, 255, 129, 139, 130, 247, 131, 141, 131, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 130, 140, 131, 232, 129, 139, 130, 251, 129, 139, 130, 251, 129, 139, 130, 236, 129, 139, 130, 253, 129, 139, 130, 255, 129, 139, 130, 247, 131, 141, 131, 230, 0, 0, 0, 0, 129, 139, 130, 242, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 129, 140, 130, 232, 129, 139, 130, 251, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 247, 131, 141, 131, 230, 0, 0, 0, 0, 129, 139, 130, 242, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 130, 140, 130, 232, 129, 139, 130, 251, 129, 139, 130, 255, 129, 139, 130, 247, 131, 141, 131, 230, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 130, 140, 130, 232, 129, 139, 130, 244, 131, 141, 131, 230, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 255, 129, 139, 130, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 129, 139, 130, 252, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 252, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 130, 139, 130, 237, 129, 139, 130, 252, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 255, 129, 139, 130, 252, 129, 139, 130, 237, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_qagbu"]
+image = SubResource("Image_8d0da")
+
+[node name="StatusBar" type="PanelContainer"]
+clip_contents = true
+anchors_preset = 10
+anchor_right = 1.0
+offset_right = -807.0
+offset_bottom = 31.0
+grow_horizontal = 2
+size_flags_horizontal = 3
+size_flags_vertical = 0
+script = ExtResource("3")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+layout_mode = 2
+size_flags_vertical = 0
+
+[node name="tree_tools" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 0
+
+[node name="Label" type="Label" parent="VBoxContainer/tree_tools"]
+layout_mode = 2
+size_flags_horizontal = 0
+text = "Statistics"
+
+[node name="tree_buttons" type="HBoxContainer" parent="VBoxContainer/tree_tools"]
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+alignment = 2
+
+[node name="VSeparator" type="VSeparator" parent="VBoxContainer/tree_tools/tree_buttons"]
+layout_mode = 2
+
+[node name="btn_tree_sync" type="Button" parent="VBoxContainer/tree_tools/tree_buttons"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Run discover tests."
+icon = SubResource("DPITexture_mb3ih")
+
+[node name="btn_tree_sort" type="MenuButton" parent="VBoxContainer/tree_tools/tree_buttons"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Sets tree sorting mode."
+icon = SubResource("DPITexture_wo03e")
+flat = false
+item_count = 4
+popup/item_0/text = "Unsorted"
+popup/item_0/icon = SubResource("DPITexture_ixycx")
+popup/item_0/checkable = 1
+popup/item_0/id = 0
+popup/item_1/text = "Name ascending"
+popup/item_1/icon = SubResource("DPITexture_wo03e")
+popup/item_1/checkable = 1
+popup/item_1/checked = true
+popup/item_1/id = 1
+popup/item_2/text = "Name descending"
+popup/item_2/icon = SubResource("ImageTexture_eis20")
+popup/item_2/checkable = 1
+popup/item_2/id = 2
+popup/item_3/text = "Execution time"
+popup/item_3/icon = SubResource("DPITexture_t2qd7")
+popup/item_3/checkable = 1
+popup/item_3/id = 3
+
+[node name="btn_tree_mode" type="MenuButton" parent="VBoxContainer/tree_tools/tree_buttons"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "Sets tree presentation mode."
+icon = SubResource("ImageTexture_1mh1t")
+flat = false
+item_count = 2
+popup/item_0/text = "Tree"
+popup/item_0/icon = SubResource("ImageTexture_bq8kn")
+popup/item_0/checkable = 1
+popup/item_0/checked = true
+popup/item_0/id = 0
+popup/item_1/text = "Flat"
+popup/item_1/icon = SubResource("ImageTexture_8lbfl")
+popup/item_1/checkable = 1
+popup/item_1/id = 1
+
+[node name="HSeparator" type="HSeparator" parent="VBoxContainer"]
+layout_mode = 2
+theme_override_constants/separation = 0
+
+[node name="status_bar" type="HFlowContainer" parent="VBoxContainer"]
+layout_direction = 2
+layout_mode = 2
+size_flags_vertical = 2
+
+[node name="error" type="VBoxContainer" parent="VBoxContainer/status_bar"]
+custom_minimum_size = Vector2(0, 48)
+layout_mode = 2
+size_flags_vertical = 0
+theme_override_constants/separation = -2
+
+[node name="icon" type="HBoxContainer" parent="VBoxContainer/status_bar/error"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="icon_errors" type="TextureRect" parent="VBoxContainer/status_bar/error/icon"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Error Tests"
+texture = SubResource("ImageTexture_ivm1h")
+stretch_mode = 3
+
+[node name="btn_up" type="Button" parent="VBoxContainer/status_bar/error/icon"]
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Jump to the previous error test"
+icon = SubResource("ImageTexture_1oriu")
+
+[node name="counter" type="HBoxContainer" parent="VBoxContainer/status_bar/error"]
+auto_translate_mode = 2
+layout_mode = 2
+localize_numeral_system = false
+
+[node name="error_value" type="Label" parent="VBoxContainer/status_bar/error/counter"]
+unique_name_in_owner = true
+use_parent_material = true
+custom_minimum_size = Vector2(32, 0)
+layout_mode = 2
+size_flags_horizontal = 10
+text = "0"
+horizontal_alignment = 2
+justification_flags = 0
+visible_characters = 3
+visible_ratio = 3.0
+
+[node name="btn_down" type="Button" parent="VBoxContainer/status_bar/error/counter"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+tooltip_text = "Jump to the next error test"
+icon = SubResource("ImageTexture_ikyhk")
+
+[node name="VSeparator" type="VSeparator" parent="VBoxContainer/status_bar"]
+layout_mode = 2
+
+[node name="failure" type="VBoxContainer" parent="VBoxContainer/status_bar"]
+custom_minimum_size = Vector2(0, 48)
+layout_mode = 2
+theme_override_constants/separation = -2
+
+[node name="icon" type="HBoxContainer" parent="VBoxContainer/status_bar/failure"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="icon_failures" type="TextureRect" parent="VBoxContainer/status_bar/failure/icon"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Failed Tests"
+texture = SubResource("ImageTexture_suo5c")
+stretch_mode = 3
+
+[node name="btn_up" type="Button" parent="VBoxContainer/status_bar/failure/icon"]
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Jump to the previous failed test"
+icon = SubResource("ImageTexture_1oriu")
+
+[node name="counter" type="HBoxContainer" parent="VBoxContainer/status_bar/failure"]
+auto_translate_mode = 2
+layout_mode = 2
+localize_numeral_system = false
+
+[node name="failure_value" type="Label" parent="VBoxContainer/status_bar/failure/counter"]
+unique_name_in_owner = true
+use_parent_material = true
+custom_minimum_size = Vector2(32, 0)
+layout_mode = 2
+size_flags_horizontal = 10
+text = "0"
+horizontal_alignment = 2
+justification_flags = 0
+visible_characters = 3
+visible_ratio = 3.0
+
+[node name="btn_down" type="Button" parent="VBoxContainer/status_bar/failure/counter"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+tooltip_text = "Jump to the next failed test"
+icon = SubResource("ImageTexture_ikyhk")
+
+[node name="VSeparator2" type="VSeparator" parent="VBoxContainer/status_bar"]
+layout_mode = 2
+
+[node name="flaky" type="VBoxContainer" parent="VBoxContainer/status_bar"]
+custom_minimum_size = Vector2(0, 48)
+layout_mode = 2
+theme_override_constants/separation = -2
+
+[node name="icon" type="HBoxContainer" parent="VBoxContainer/status_bar/flaky"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="icon_flaky" type="TextureRect" parent="VBoxContainer/status_bar/flaky/icon"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Flaky Tests"
+texture = SubResource("ImageTexture_d5kq4")
+stretch_mode = 3
+
+[node name="btn_up" type="Button" parent="VBoxContainer/status_bar/flaky/icon"]
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Jump to the previous flaky test"
+icon = SubResource("ImageTexture_1oriu")
+
+[node name="counter" type="HBoxContainer" parent="VBoxContainer/status_bar/flaky"]
+auto_translate_mode = 2
+layout_mode = 2
+localize_numeral_system = false
+
+[node name="flaky_value" type="Label" parent="VBoxContainer/status_bar/flaky/counter"]
+unique_name_in_owner = true
+use_parent_material = true
+custom_minimum_size = Vector2(32, 0)
+layout_mode = 2
+size_flags_horizontal = 10
+text = "0"
+horizontal_alignment = 2
+justification_flags = 0
+visible_characters = 3
+visible_ratio = 3.0
+
+[node name="btn_down" type="Button" parent="VBoxContainer/status_bar/flaky/counter"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+tooltip_text = "Jump to the next flaky test"
+icon = SubResource("ImageTexture_ikyhk")
+
+[node name="VSeparator3" type="VSeparator" parent="VBoxContainer/status_bar"]
+layout_mode = 2
+
+[node name="skipped" type="VBoxContainer" parent="VBoxContainer/status_bar"]
+custom_minimum_size = Vector2(0, 48)
+layout_mode = 2
+theme_override_constants/separation = -2
+
+[node name="icon" type="HBoxContainer" parent="VBoxContainer/status_bar/skipped"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="icon_skipped" type="TextureRect" parent="VBoxContainer/status_bar/skipped/icon"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Skipped Tests"
+texture = SubResource("ImageTexture_qagbu")
+stretch_mode = 3
+
+[node name="btn_up" type="Button" parent="VBoxContainer/status_bar/skipped/icon"]
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+tooltip_text = "Jump to the previous skipped test"
+icon = SubResource("ImageTexture_1oriu")
+
+[node name="counter" type="HBoxContainer" parent="VBoxContainer/status_bar/skipped"]
+auto_translate_mode = 2
+layout_mode = 2
+localize_numeral_system = false
+
+[node name="skipped_value" type="Label" parent="VBoxContainer/status_bar/skipped/counter"]
+unique_name_in_owner = true
+use_parent_material = true
+custom_minimum_size = Vector2(32, 0)
+layout_mode = 2
+size_flags_horizontal = 10
+text = "0"
+horizontal_alignment = 2
+justification_flags = 0
+visible_characters = 3
+visible_ratio = 3.0
+
+[node name="btn_down" type="Button" parent="VBoxContainer/status_bar/skipped/counter"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+tooltip_text = "Jump to the next skipped test"
+icon = SubResource("ImageTexture_ikyhk")
+
+[connection signal="pressed" from="VBoxContainer/tree_tools/tree_buttons/btn_tree_sync" to="." method="_on_btn_tree_sync_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/error/icon/btn_up" to="." method="_on_btn_error_up_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/error/counter/btn_down" to="." method="_on_btn_error_down_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/failure/icon/btn_up" to="." method="_on_failure_up_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/failure/counter/btn_down" to="." method="_on_failure_down_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/flaky/icon/btn_up" to="." method="_on_btn_flaky_up_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/flaky/counter/btn_down" to="." method="_on_btn_flaky_down_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/skipped/icon/btn_up" to="." method="_on_btn_skipped_up_pressed"]
+[connection signal="pressed" from="VBoxContainer/status_bar/skipped/counter/btn_down" to="." method="_on_btn_skipped_down_pressed"]
diff --git a/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd b/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd
new file mode 100644
index 0000000..4a92c52
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd
@@ -0,0 +1,104 @@
+@tool
+extends PanelContainer
+
+
+const InspectorTreeMainPanel := preload("res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd")
+
+@onready var _version_label: Control = %version
+@onready var _button_wiki: Button = %help
+@onready var _tool_button: Button = %tool
+@onready var _button_run_overall: Button = %run_overall
+@onready var _button_run: Button = %run
+@onready var _button_run_debug: Button = %debug
+@onready var _button_stop: Button = %stop
+
+
+var inspector: InspectorTreeMainPanel
+var command_handler: GdUnitCommandHandler
+
+
+func _ready() -> void:
+ command_handler = GdUnitCommandHandler.instance()
+ inspector = get_parent().get_parent().find_child("MainPanel", false, false)
+ if inspector == null:
+ push_error("Internal error, can't connect to the test inspector!")
+ else:
+ inspector.tree_item_selected.connect(_on_inspector_selected)
+
+ GdUnit4Version.init_version_label(_version_label)
+
+ GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event)
+ GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed)
+ _init_buttons()
+
+
+func _init_buttons() -> void:
+ _init_button(_button_run_overall, GdUnitCommandRunTestsOverall.ID)
+ _init_button(_button_run, GdUnitCommandInspectorRunTests.ID)
+ _init_button(_button_run_debug, GdUnitCommandInspectorDebugTests.ID)
+ _init_button(_button_stop, GdUnitCommandStopTestSession.ID)
+
+ _button_stop.icon = command_handler.command_icon(GdUnitCommandStopTestSession.ID)
+ _tool_button.icon = GdUnitUiTools.get_icon("Tools")
+ _button_wiki.icon = GdUnitUiTools.get_icon("HelpSearch")
+ # Set run buttons initial disabled
+ _button_run.disabled = true
+ _button_run_debug.disabled = true
+
+
+func _init_button(button: Button, comand_id: String) -> void:
+ button.set_meta("GdUnitCommand", comand_id)
+ button.icon = command_handler.command_icon(comand_id)
+ button.shortcut = command_handler.command_shortcut(comand_id)
+ if button == _button_run_overall:
+ button.visible = GdUnitSettings.is_inspector_toolbar_button_show()
+
+
+func _on_inspector_selected(item: TreeItem) -> void:
+ var button_disabled := item == null
+ _button_run.disabled = button_disabled
+ _button_run_debug.disabled = button_disabled
+
+
+func _on_gdunit_event(event: GdUnitEvent) -> void:
+ if event.type() == GdUnitEvent.SESSION_START:
+ _button_run_overall.disabled = true
+ _button_run.disabled = true
+ _button_run_debug.disabled = true
+ _button_stop.disabled = false
+ return
+ if event.type() == GdUnitEvent.SESSION_CLOSE:
+ _button_run_overall.disabled = false
+ _button_stop.disabled = true
+
+
+func _on_button_pressed(source: BaseButton) -> void:
+ var command_id: String = source.get_meta("GdUnitCommand")
+ await command_handler.command_execute(command_id)
+
+
+func _on_wiki_pressed() -> void:
+ var status := OS.shell_open("https://godot-gdunit-labs.github.io/gdUnit4/latest")
+ if status != OK:
+ push_error("Can't open GdUnit4 documentaion page: %s" % error_string(status))
+
+
+func _on_btn_tool_pressed() -> void:
+ var settings_dlg: Window = EditorInterface.get_base_control().find_child("GdUnitSettingsDialog", false, false)
+ if settings_dlg == null:
+ settings_dlg = preload("res://addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn").instantiate()
+ EditorInterface.get_base_control().add_child(settings_dlg, true)
+ settings_dlg.popup_centered_ratio(.60)
+
+
+func _on_settings_changed(property: GdUnitProperty) -> void:
+ # needs to wait a frame to be command handler notified first for settings changes
+ await get_tree().process_frame
+
+ _button_run_overall.visible = GdUnitSettings.is_inspector_toolbar_button_show()
+
+ if property.name().begins_with(GdUnitSettings.GROUP_SHORTCUT_INSPECTOR):
+ _button_run.shortcut = command_handler.command_shortcut(GdUnitCommandInspectorRunTests.ID)
+ _button_run_debug.shortcut = command_handler.command_shortcut(GdUnitCommandInspectorDebugTests.ID)
+ _button_run_overall.shortcut = command_handler.command_shortcut(GdUnitCommandRunTestsOverall.ID)
+ _button_stop.shortcut = command_handler.command_shortcut(GdUnitCommandStopTestSession.ID)
diff --git a/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd.uid
new file mode 100644
index 0000000..69c8748
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorToolBar.gd.uid
@@ -0,0 +1 @@
+uid://bepy3y6bvaume
diff --git a/addons/gdUnit4/src/ui/parts/InspectorToolBar.tscn b/addons/gdUnit4/src/ui/parts/InspectorToolBar.tscn
new file mode 100644
index 0000000..8cd529e
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorToolBar.tscn
@@ -0,0 +1,202 @@
+[gd_scene load_steps=17 format=3 uid="uid://dx7xy4dgi3wwb"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/InspectorToolBar.gd" id="3"]
+
+[sub_resource type="DPITexture" id="DPITexture_c7rhl"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M6.973 7.441a4 4 0 1 0-1.414-1.414L3.293 8.293l1.414 1.414zM9 2a2 2 0 0 1 0 4 2 2 0 0 1 0-4zm-9 8v6h2a3 3 0 1 0 0-6zm5 3a3 3 0 1 0 6 0 3 3 0 0 0-6 0zm6 0a3 3 0 0 0 3 3h1v-2h-1a1 1 0 0 1 0-2h1v-2h-1a3 3 0 0 0-3 3zm-9-1a1 1 0 0 1 0 2zm6 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z\"/></svg>
+"
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_3erui"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M4 1 3 3l1 2v4H2v3.5a2.5 2.5 0 0 0 5 0V9H5V5l1-2-1-2zm6 .174a3 3 0 0 0 0 5.652V14a1 1 0 0 0 2 0V6.824a3 3 0 0 0 0-5.648V4a1 1 0 0 1-2 0z\"/></svg>
+"
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="InputEventKey" id="InputEventKey_p22nw"]
+alt_pressed = true
+pressed = true
+keycode = 4194338
+physical_keycode = 4194338
+
+[sub_resource type="Shortcut" id="Shortcut_3lcek"]
+events = [SubResource("InputEventKey_p22nw")]
+
+[sub_resource type="Image" id="Image_ndw0i"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 195, 224, 224, 224, 210, 224, 224, 224, 56, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 195, 224, 224, 224, 210, 224, 224, 224, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 253, 224, 224, 224, 139, 224, 224, 224, 8, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 253, 224, 224, 224, 139, 224, 224, 224, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 225, 225, 225, 215, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 225, 225, 225, 215, 224, 224, 224, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 253, 224, 224, 224, 139, 224, 224, 224, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 225, 225, 225, 183, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 225, 225, 225, 182, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 252, 225, 225, 225, 134, 255, 255, 255, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 212, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 212, 226, 226, 226, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 252, 225, 225, 225, 134, 255, 255, 255, 6, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 252, 225, 225, 225, 134, 255, 255, 255, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 225, 225, 225, 191, 224, 224, 224, 206, 226, 226, 226, 52, 0, 0, 0, 0, 0, 0, 0, 0, 225, 225, 225, 191, 224, 224, 224, 206, 226, 226, 226, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_eoihf"]
+image = SubResource("Image_ndw0i")
+
+[sub_resource type="InputEventKey" id="InputEventKey_6gwbs"]
+alt_pressed = true
+pressed = true
+keycode = 4194336
+physical_keycode = 4194336
+
+[sub_resource type="Shortcut" id="Shortcut_2i8uq"]
+events = [SubResource("InputEventKey_6gwbs")]
+
+[sub_resource type="DPITexture" id="DPITexture_87jj1"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M4 12a1 1 0 0 0 1.555.832l6-4a1 1 0 0 0 0-1.664l-6-4A1 1 0 0 0 4 4z\"/></svg>
+"
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="InputEventKey" id="InputEventKey_fewwl"]
+alt_pressed = true
+pressed = true
+keycode = 4194337
+physical_keycode = 4194337
+
+[sub_resource type="Shortcut" id="Shortcut_f3lkx"]
+events = [SubResource("InputEventKey_fewwl")]
+
+[sub_resource type="DPITexture" id="DPITexture_7oobd"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M3 3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm4.975 0A1 1 0 0 0 7 3.998v8a1 1 0 0 0 1.625.781l5-4a1 1 0 0 0 0-1.56l-5-4A1 1 0 0 0 7.975 3z\"/></svg>
+"
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(1, 0.87, 0.4, 1)
+}
+
+[sub_resource type="InputEventKey" id="InputEventKey_lvbnb"]
+alt_pressed = true
+pressed = true
+keycode = 4194339
+physical_keycode = 4194339
+
+[sub_resource type="Shortcut" id="Shortcut_6idxu"]
+events = [SubResource("InputEventKey_lvbnb")]
+
+[sub_resource type="DPITexture" id="DPITexture_qf2s1"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><rect width=\"10\" height=\"10\" x=\"3\" y=\"3\" fill=\"#e0e0e0\" rx=\"1\"/></svg>
+"
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(1, 0.87, 0.4, 1)
+}
+
+[node name="ToolBar" type="PanelContainer"]
+anchors_preset = 10
+anchor_right = 1.0
+offset_right = -894.0
+offset_bottom = 24.0
+grow_horizontal = 2
+size_flags_horizontal = 3
+size_flags_vertical = 4
+script = ExtResource("3")
+
+[node name="HBoxContainer" type="HBoxContainer" parent="."]
+layout_mode = 2
+
+[node name="tools" type="HBoxContainer" parent="HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 0
+size_flags_vertical = 4
+
+[node name="help" type="Button" parent="HBoxContainer/tools"]
+unique_name_in_owner = true
+layout_mode = 2
+icon = SubResource("DPITexture_c7rhl")
+
+[node name="tool" type="Button" parent="HBoxContainer/tools"]
+unique_name_in_owner = true
+layout_mode = 2
+tooltip_text = "GdUnit Settings"
+icon = SubResource("DPITexture_3erui")
+
+[node name="controls" type="HBoxContainer" parent="HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 6
+size_flags_vertical = 4
+alignment = 1
+
+[node name="VSeparator3" type="VSeparator" parent="HBoxContainer/controls"]
+layout_mode = 2
+
+[node name="run_overall" type="Button" parent="HBoxContainer/controls"]
+unique_name_in_owner = true
+use_parent_material = true
+layout_mode = 2
+tooltip_text = "Run overall tests"
+shortcut = SubResource("Shortcut_3lcek")
+icon = SubResource("ImageTexture_eoihf")
+metadata/GdUnitCommand = "Run Tests Overall"
+
+[node name="run" type="Button" parent="HBoxContainer/controls"]
+unique_name_in_owner = true
+use_parent_material = true
+layout_mode = 2
+tooltip_text = "Rerun unit tests"
+disabled = true
+shortcut = SubResource("Shortcut_2i8uq")
+icon = SubResource("DPITexture_87jj1")
+metadata/GdUnitCommand = "Run Inspector Tests"
+
+[node name="debug" type="Button" parent="HBoxContainer/controls"]
+unique_name_in_owner = true
+use_parent_material = true
+layout_mode = 2
+tooltip_text = "Rerun unit tests (Debug)"
+disabled = true
+shortcut = SubResource("Shortcut_f3lkx")
+icon = SubResource("DPITexture_7oobd")
+metadata/GdUnitCommand = "Debug Inspector Tests"
+
+[node name="stop" type="Button" parent="HBoxContainer/controls"]
+unique_name_in_owner = true
+use_parent_material = true
+layout_mode = 2
+tooltip_text = "Stops runing unit tests"
+disabled = true
+shortcut = SubResource("Shortcut_6idxu")
+icon = SubResource("DPITexture_qf2s1")
+metadata/GdUnitCommand = "Stop Test Session"
+
+[node name="VSeparator4" type="VSeparator" parent="HBoxContainer/controls"]
+layout_mode = 2
+
+[node name="CenterContainer" type="HBoxContainer" parent="HBoxContainer"]
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 10
+size_flags_vertical = 4
+alignment = 2
+
+[node name="version" type="Label" parent="HBoxContainer/CenterContainer"]
+unique_name_in_owner = true
+auto_translate_mode = 2
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 13
+localize_numeral_system = false
+text = "gdUnit4 6.1.0"
+horizontal_alignment = 1
+justification_flags = 160
+
+[connection signal="pressed" from="HBoxContainer/tools/help" to="." method="_on_wiki_pressed"]
+[connection signal="pressed" from="HBoxContainer/tools/tool" to="." method="_on_btn_tool_pressed"]
+[connection signal="pressed" from="HBoxContainer/controls/run_overall" to="." method="_on_button_pressed" flags=18]
+[connection signal="pressed" from="HBoxContainer/controls/run" to="." method="_on_button_pressed" flags=18]
+[connection signal="pressed" from="HBoxContainer/controls/debug" to="." method="_on_button_pressed" flags=18]
+[connection signal="pressed" from="HBoxContainer/controls/stop" to="." method="_on_button_pressed" flags=18]
diff --git a/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd
new file mode 100644
index 0000000..f55dbee
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd
@@ -0,0 +1,1210 @@
+@tool
+extends VSplitContainer
+
+## Will be emitted when the test index counter is changed
+signal test_counters_changed(index: int, total: int, state: GdUnitInspectorTreeConstants.STATE)
+signal tree_item_selected(item: TreeItem)
+
+
+@onready var _tree: Tree = $Panel/Tree
+@onready var _report_list: Node = $report/ScrollContainer/list
+@onready var _report_template: RichTextLabel = $report/report_template
+@onready var _context_menu: GdUnitInspectorContextMenu = $contextMenu
+@onready var _discover_hint: Control = %discover_hint
+@onready var _spinner: Button = %spinner
+
+# loading tree icons
+@onready var ICON_SPINNER := GdUnitUiTools.get_spinner()
+@onready var ICON_FOLDER := GdUnitUiTools.get_icon("Folder")
+# gdscript icons
+@onready var ICON_GDSCRIPT_TEST_DEFAULT := GdUnitUiTools.get_icon("GDScript", Color.LIGHT_GRAY)
+@onready var ICON_GDSCRIPT_TEST_SUCCESS := GdUnitUiTools.get_GDScript_icon("StatusSuccess", Color.DARK_GREEN)
+@onready var ICON_GDSCRIPT_TEST_FLAKY := GdUnitUiTools.get_GDScript_icon("CheckBox", Color.GREEN_YELLOW)
+@onready var ICON_GDSCRIPT_TEST_FAILED := GdUnitUiTools.get_GDScript_icon("StatusError", Color.SKY_BLUE)
+@onready var ICON_GDSCRIPT_TEST_ERROR := GdUnitUiTools.get_GDScript_icon("StatusError", Color.DARK_RED)
+@onready var ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN := GdUnitUiTools.get_GDScript_icon("Unlinked", Color.DARK_GREEN)
+@onready var ICON_GDSCRIPT_TEST_FAILED_ORPHAN := GdUnitUiTools.get_GDScript_icon("Unlinked", Color.SKY_BLUE)
+@onready var ICON_GDSCRIPT_TEST_ERRORS_ORPHAN := GdUnitUiTools.get_GDScript_icon("Unlinked", Color.DARK_RED)
+# csharp script icons
+@onready var ICON_CSSCRIPT_TEST_DEFAULT := GdUnitUiTools.get_icon("CSharpScript", Color.LIGHT_GRAY)
+@onready var ICON_CSSCRIPT_TEST_SUCCESS := GdUnitUiTools.get_CSharpScript_icon("StatusSuccess", Color.DARK_GREEN)
+@onready var ICON_CSSCRIPT_TEST_FAILED := GdUnitUiTools.get_CSharpScript_icon("StatusError", Color.SKY_BLUE)
+@onready var ICON_CSSCRIPT_TEST_ERROR := GdUnitUiTools.get_CSharpScript_icon("StatusError", Color.DARK_RED)
+@onready var ICON_CSSCRIPT_TEST_SUCCESS_ORPHAN := GdUnitUiTools.get_CSharpScript_icon("Unlinked", Color.DARK_GREEN)
+@onready var ICON_CSSCRIPT_TEST_FAILED_ORPHAN := GdUnitUiTools.get_CSharpScript_icon("Unlinked", Color.SKY_BLUE)
+@onready var ICON_CSSCRIPT_TEST_ERRORS_ORPHAN := GdUnitUiTools.get_CSharpScript_icon("Unlinked", Color.DARK_RED)
+
+
+enum GdUnitType {
+ FOLDER,
+ TEST_SUITE,
+ TEST_CASE,
+ TEST_GROUP
+}
+
+const META_GDUNIT_PROGRESS_COUNT_MAX := "gdUnit_progress_count_max"
+const META_GDUNIT_PROGRESS_INDEX := "gdUnit_progress_index"
+const META_TEST_CASE := "gdunit_test_case"
+const META_GDUNIT_NAME := "gdUnit_name"
+const META_GDUNIT_STATE := "gdUnit_state"
+const META_GDUNIT_TYPE := "gdUnit_type"
+const META_GDUNIT_SUCCESS_TESTS := "gdUnit_suite_success_tests"
+const META_GDUNIT_REPORT := "gdUnit_report"
+const META_GDUNIT_ORPHAN := "gdUnit_orphan"
+const META_GDUNIT_EXECUTION_TIME := "gdUnit_execution_time"
+const META_GDUNIT_ORIGINAL_INDEX = "gdunit_original_index"
+const STATE = GdUnitInspectorTreeConstants.STATE
+
+
+var _tree_root: TreeItem
+var _current_selected_item: TreeItem = null
+var _current_tree_view_mode := GdUnitSettings.get_inspector_tree_view_mode()
+var _run_test_recovery := true
+
+
+## Used for debugging purposes only
+func print_tree_item_ids(parent: TreeItem) -> TreeItem:
+ for child in parent.get_children():
+ if child.has_meta(META_TEST_CASE):
+ var test_case: GdUnitTestCase = child.get_meta(META_TEST_CASE)
+ prints(test_case.guid, test_case.test_name)
+
+ if child.get_child_count() > 0:
+ print_tree_item_ids(child)
+
+ return null
+
+
+func _find_tree_item(parent: TreeItem, item_name: String) -> TreeItem:
+ for child in parent.get_children():
+ if child.get_meta(META_GDUNIT_NAME) == item_name:
+ return child
+ return null
+
+
+func _find_tree_item_by_id(parent: TreeItem, id: GdUnitGUID) -> TreeItem:
+ for child in parent.get_children():
+ if is_test_id(child, id):
+ return child
+ if child.get_child_count() > 0:
+ var item := _find_tree_item_by_id(child, id)
+ if item != null:
+ return item
+
+ return null
+
+
+func _find_tree_item_by_test_suite(parent: TreeItem, suite_path: String, suite_name: String) -> TreeItem:
+ for child in parent.get_children():
+ if child.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_SUITE:
+ var test_case: GdUnitTestCase = child.get_meta(META_TEST_CASE)
+ if test_case.suite_resource_path == suite_path and test_case.suite_name == suite_name:
+ return child
+ if child.get_child_count() > 0:
+ var item := _find_tree_item_by_test_suite(child, suite_path, suite_name)
+ if item != null:
+ return item
+ return null
+
+
+func _find_first_item_by_state(parent: TreeItem, item_state: STATE, reverse := false) -> TreeItem:
+ var itmes := parent.get_children()
+ if reverse:
+ itmes.reverse()
+ for item in itmes:
+ if is_test_case(item) and (is_item_state(item, item_state)):
+ return item
+ var failure_item := _find_first_item_by_state(item, item_state, reverse)
+ if failure_item != null:
+ return failure_item
+ return null
+
+
+func _find_last_item_by_state(parent: TreeItem, item_state: STATE) -> TreeItem:
+ return _find_first_item_by_state(parent, item_state, true)
+
+
+func _find_item_by_state(current: TreeItem, item_state: STATE, prev := false) -> TreeItem:
+ var next := current.get_prev_in_tree() if prev else current.get_next_in_tree()
+ if next == null or next == _tree_root:
+ return null
+ if is_test_case(next) and is_item_state(next, item_state):
+ return next
+ return _find_item_by_state(next, item_state, prev)
+
+
+func is_item_state(item: TreeItem, item_state: STATE) -> bool:
+ return item.has_meta(META_GDUNIT_STATE) and item.get_meta(META_GDUNIT_STATE) == item_state
+
+
+func is_state_running(item: TreeItem) -> bool:
+ return is_item_state(item, STATE.RUNNING)
+
+
+func is_state_success(item: TreeItem) -> bool:
+ return is_item_state(item, STATE.SUCCESS)
+
+
+func is_state_warning(item: TreeItem) -> bool:
+ return is_item_state(item, STATE.WARNING)
+
+
+func is_state_failed(item: TreeItem) -> bool:
+ return is_item_state(item, STATE.FAILED)
+
+
+func is_state_error(item: TreeItem) -> bool:
+ return is_item_state(item, STATE.ERROR) or is_item_state(item, STATE.ABORDED)
+
+
+func is_item_state_orphan(item: TreeItem) -> bool:
+ return item.has_meta(META_GDUNIT_ORPHAN)
+
+
+func is_test_suite(item: TreeItem) -> bool:
+ return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_SUITE
+
+
+func is_test_case(item: TreeItem) -> bool:
+ return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.TEST_CASE
+
+
+func is_folder(item: TreeItem) -> bool:
+ return item.has_meta(META_GDUNIT_TYPE) and item.get_meta(META_GDUNIT_TYPE) == GdUnitType.FOLDER
+
+
+func is_test_id(item: TreeItem, id: GdUnitGUID) -> bool:
+ if not item.has_meta(META_TEST_CASE):
+ return false
+
+ var test_case: GdUnitTestCase = item.get_meta(META_TEST_CASE)
+ return test_case.guid.equals(id)
+
+
+func disable_test_recovery() -> void:
+ _run_test_recovery = false
+
+
+@warning_ignore("return_value_discarded")
+func _ready() -> void:
+ if Engine.is_editor_hint():
+ var base_control := EditorInterface.get_base_control()
+ base_control.set_meta("GdUnit4Inspector", self)
+
+ _spinner.icon = GdUnitUiTools.get_spinner()
+ init_tree()
+ GdUnitSignals.instance().gdunit_settings_changed.connect(_on_settings_changed)
+ GdUnitSignals.instance().gdunit_event.connect(_on_gdunit_event)
+ GdUnitSignals.instance().gdunit_test_discover_added.connect(on_test_case_discover_added)
+ GdUnitSignals.instance().gdunit_test_discover_deleted.connect(on_test_case_discover_deleted)
+ GdUnitSignals.instance().gdunit_test_discover_modified.connect(on_test_case_discover_modified)
+ if _run_test_recovery:
+ GdUnitTestDiscoverer.restore_last_session()
+
+
+# we need current to manually redraw bacause of the animation bug
+# https://github.com/godotengine/godot/issues/69330
+func _process(_delta: float) -> void:
+ if is_visible_in_tree():
+ queue_redraw()
+
+
+func init_tree() -> void:
+ cleanup_tree()
+ _tree.deselect_all()
+ _tree.set_hide_root(true)
+ _tree.ensure_cursor_is_visible()
+ _tree.set_allow_reselect(true)
+ _tree.set_allow_rmb_select(true)
+ _tree.set_columns(2)
+ _tree.set_column_clip_content(0, true)
+ _tree.set_column_expand_ratio(0, 1)
+ _tree.set_column_custom_minimum_width(0, 240)
+ _tree.set_column_expand_ratio(1, 0)
+ _tree.set_column_custom_minimum_width(1, 100)
+ _tree_root = _tree.create_item()
+ _tree_root.set_text(0, "tree_root")
+ _tree_root.set_meta(META_GDUNIT_NAME, "tree_root")
+ _tree_root.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0)
+ _tree_root.set_meta(META_GDUNIT_PROGRESS_INDEX, 0)
+ _tree_root.set_meta(META_GDUNIT_STATE, STATE.INITIAL)
+ _tree_root.set_meta(META_GDUNIT_SUCCESS_TESTS, 0)
+ # fix tree icon scaling
+ var scale_factor := EditorInterface.get_editor_scale() if Engine.is_editor_hint() else 1.0
+ _tree.set("theme_override_constants/icon_max_width", 16 * scale_factor)
+
+
+func cleanup_tree() -> void:
+ clear_reports()
+ if not _tree_root:
+ return
+ _free_recursive()
+ _tree.clear()
+ _current_selected_item = null
+
+
+func _free_recursive(items:=_tree_root.get_children()) -> void:
+ for item in items:
+ _free_recursive(item.get_children())
+ item.call_deferred("free")
+
+
+func sort_tree_items(parent: TreeItem) -> void:
+ _sort_tree_items(parent, GdUnitSettings.get_inspector_tree_sort_mode())
+ _tree.queue_redraw()
+
+
+static func _sort_tree_items(parent: TreeItem, sort_mode: GdUnitInspectorTreeConstants.SORT_MODE) -> void:
+ parent.visible = false
+ var items := parent.get_children()
+ # first remove all childs before sorting
+ for item in items:
+ parent.remove_child(item)
+
+ # do sort by selected sort mode
+ match sort_mode:
+ GdUnitInspectorTreeConstants.SORT_MODE.UNSORTED:
+ items.sort_custom(sort_items_by_original_index)
+
+ GdUnitInspectorTreeConstants.SORT_MODE.NAME_ASCENDING:
+ items.sort_custom(sort_items_by_name.bind(true))
+
+ GdUnitInspectorTreeConstants.SORT_MODE.NAME_DESCENDING:
+ items.sort_custom(sort_items_by_name.bind(false))
+
+ GdUnitInspectorTreeConstants.SORT_MODE.EXECUTION_TIME:
+ items.sort_custom(sort_items_by_execution_time)
+
+ # readding sorted childs
+ for item in items:
+ parent.add_child(item)
+ if item.get_child_count() > 0:
+ _sort_tree_items(item, sort_mode)
+ parent.visible = true
+
+
+static func sort_items_by_name(a: TreeItem, b: TreeItem, ascending: bool) -> bool:
+ var type_a: GdUnitType = a.get_meta(META_GDUNIT_TYPE)
+ var type_b: GdUnitType = b.get_meta(META_GDUNIT_TYPE)
+
+ # Sort folders to the top
+ if type_a == GdUnitType.FOLDER and type_b != GdUnitType.FOLDER:
+ return true
+ if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER:
+ return false
+
+ # sort by name
+ var name_a: String = a.get_meta(META_GDUNIT_NAME)
+ var name_b: String = b.get_meta(META_GDUNIT_NAME)
+ var comparison := name_a.naturalnocasecmp_to(name_b)
+
+ return comparison < 0 if ascending else comparison > 0
+
+
+static func sort_items_by_execution_time(a: TreeItem, b: TreeItem) -> bool:
+ var type_a: GdUnitType = a.get_meta(META_GDUNIT_TYPE)
+ var type_b: GdUnitType = b.get_meta(META_GDUNIT_TYPE)
+
+ # Sort folders to the top
+ if type_a == GdUnitType.FOLDER and type_b != GdUnitType.FOLDER:
+ return true
+ if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER:
+ return false
+
+ var execution_time_a :int = a.get_meta(META_GDUNIT_EXECUTION_TIME)
+ var execution_time_b :int = b.get_meta(META_GDUNIT_EXECUTION_TIME)
+ # if has same execution time sort by name
+ if execution_time_a == execution_time_b:
+ var name_a :String = a.get_meta(META_GDUNIT_NAME)
+ var name_b :String = b.get_meta(META_GDUNIT_NAME)
+ return name_a.naturalnocasecmp_to(name_b) > 0
+ return execution_time_a > execution_time_b
+
+
+static func sort_items_by_original_index(a: TreeItem, b: TreeItem) -> bool:
+ var type_a: GdUnitType = a.get_meta(META_GDUNIT_TYPE)
+ var type_b: GdUnitType = b.get_meta(META_GDUNIT_TYPE)
+
+ # Sort folders to the top
+ if type_a == GdUnitType.FOLDER and type_b != GdUnitType.FOLDER:
+ return true
+ if type_b == GdUnitType.FOLDER and type_a != GdUnitType.FOLDER:
+ return false
+
+ var index_a :int = a.get_meta(META_GDUNIT_ORIGINAL_INDEX)
+ var index_b :int = b.get_meta(META_GDUNIT_ORIGINAL_INDEX)
+
+ # Sorting by index
+ return index_a < index_b
+
+
+func restructure_tree(parent: TreeItem, tree_mode: GdUnitInspectorTreeConstants.TREE_VIEW_MODE) -> void:
+ _current_tree_view_mode = tree_mode
+
+ match tree_mode:
+ GdUnitInspectorTreeConstants.TREE_VIEW_MODE.FLAT:
+ restructure_tree_to_flat(parent)
+ GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE:
+ restructure_tree_to_tree(parent)
+ recalculate_counters(_tree_root)
+ # finally apply actual sort mode
+ sort_tree_items(_tree_root)
+
+
+# Restructure into flat mode
+func restructure_tree_to_flat(parent: TreeItem) -> void:
+ var folders := flatmap_folders(parent)
+ # Store current folder paths and their test suites
+ for folder_path: String in folders:
+ var test_suites: Array[TreeItem] = folders[folder_path]
+ if test_suites.is_empty():
+ continue
+
+ # Create flat folder and move test suites into it
+ var folder := _tree.create_item(parent)
+ folder.set_meta(META_GDUNIT_NAME, folder_path)
+ update_item_total_counter(folder)
+ set_state_initial(folder, GdUnitType.FOLDER)
+
+ # Move test suites under the flat folder
+ for test_suite in test_suites:
+ var old_parent := test_suite.get_parent()
+ old_parent.remove_child(test_suite)
+ folder.add_child(test_suite)
+
+ # Cleanup old folder structure
+ cleanup_empty_folders(parent)
+
+
+# Restructure into hierarchical tree mode
+func restructure_tree_to_tree(parent: TreeItem) -> void:
+ var items_to_process := parent.get_children().duplicate()
+
+ for item: TreeItem in items_to_process:
+ if is_folder(item):
+ var folder_path: String = item.get_meta(META_GDUNIT_NAME)
+ var parts := folder_path.split("/")
+
+ if parts.size() > 1:
+ var current_parent := parent
+ # Build folder hierarchy
+ for part in parts:
+ var next := _find_tree_item(current_parent, part)
+ if not next:
+ next = _tree.create_item(current_parent)
+ next.set_meta(META_GDUNIT_NAME, part)
+ set_state_initial(next, GdUnitType.FOLDER)
+ current_parent = next
+
+ # Move test suites to deepest folder
+ var test_suites := item.get_children()
+ for test_suite in test_suites:
+ item.remove_child(test_suite)
+ current_parent.add_child(test_suite)
+
+ # Remove the flat folder
+ item.get_parent().remove_child(item)
+ item.free()
+
+
+func flatmap_folders(parent: TreeItem) -> Dictionary:
+ var folder_map := {}
+
+ for item in parent.get_children():
+ if is_folder(item):
+ var current_path: String = item.get_meta(META_GDUNIT_NAME)
+ # Get parent folder paths
+ var parent_path := get_parent_folder_path(item)
+ if parent_path:
+ current_path = parent_path + "/" + current_path
+
+ # Collect direct children of this folder
+ var children: Array[TreeItem] = []
+ for child in item.get_children():
+ if is_test_suite(child):
+ children.append(child)
+
+ # Add children to existing path or create new entry
+ if not children.is_empty():
+ if folder_map.has(current_path):
+ @warning_ignore("unsafe_method_access")
+ folder_map[current_path].append_array(children)
+ else:
+ folder_map[current_path] = children
+
+ # Recursively process subfolders
+ var sub_folders := flatmap_folders(item)
+ for path: String in sub_folders.keys():
+ if folder_map.has(path):
+ @warning_ignore("unsafe_method_access")
+ folder_map[path].append_array(sub_folders[path])
+ else:
+ folder_map[path] = sub_folders[path]
+ return folder_map
+
+
+func get_parent_folder_path(item: TreeItem) -> String:
+ var path := ""
+ var parent := item.get_parent()
+
+ while parent != _tree_root:
+ if is_folder(parent):
+ path = parent.get_meta(META_GDUNIT_NAME) + ("/" + path if path else "")
+ parent = parent.get_parent()
+
+ return path
+
+
+func cleanup_empty_folders(parent: TreeItem) -> void:
+ var folders: Array[TreeItem] = []
+ # First collect all folders to avoid modification during iteration
+ for item in parent.get_children():
+ if is_folder(item):
+ folders.append(item)
+
+ # Process collected folders
+ for folder in folders:
+ cleanup_empty_folders(folder)
+ # Remove folder if it has no children after cleanup
+ if folder.get_child_count() == 0:
+ parent.remove_child(folder)
+ folder.free()
+
+
+func reset_tree_state(parent: TreeItem) -> void:
+ if parent == _tree_root:
+ _tree_root.set_meta(META_GDUNIT_PROGRESS_INDEX, 0)
+ _tree_root.set_meta(META_GDUNIT_STATE, STATE.INITIAL)
+ test_counters_changed.emit(0, 0, STATE.INITIAL)
+
+ for item in parent.get_children():
+ set_state_initial(item, get_item_type(item))
+ reset_tree_state(item)
+
+
+func select_item(item: TreeItem) -> TreeItem:
+ if item != null:
+ # enshure the parent is collapsed
+ do_collapse_parent(item)
+ item.select(0)
+ _tree.ensure_cursor_is_visible()
+ _tree.scroll_to_item(item, true)
+ return item
+
+
+func do_collapse_parent(item: TreeItem) -> void:
+ if item != null:
+ item.collapsed = false
+ do_collapse_parent(item.get_parent())
+
+
+func do_collapse_all(collapse: bool, parent := _tree_root) -> void:
+ for item in parent.get_children():
+ item.collapsed = collapse
+ if not collapse:
+ do_collapse_all(collapse, item)
+
+
+func set_state_initial(item: TreeItem, type: GdUnitType) -> void:
+ item.set_text(0, str(item.get_meta(META_GDUNIT_NAME)))
+ item.set_custom_color(0, Color.LIGHT_GRAY)
+ item.set_tooltip_text(0, "")
+ item.set_text_overrun_behavior(0, TextServer.OVERRUN_TRIM_CHAR)
+ item.set_expand_right(0, true)
+
+ item.set_custom_color(1, Color.LIGHT_GRAY)
+ item.set_text(1, "")
+ item.set_expand_right(1, true)
+ item.set_tooltip_text(1, "")
+
+ item.set_meta(META_GDUNIT_STATE, STATE.INITIAL)
+ item.set_meta(META_GDUNIT_TYPE, type)
+ item.set_meta(META_GDUNIT_SUCCESS_TESTS, 0)
+ item.set_meta(META_GDUNIT_EXECUTION_TIME, 0)
+ if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX) and item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX) > 0:
+ item.set_text(0, "(0/%d) %s" % [item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)])
+ item.remove_meta(META_GDUNIT_REPORT)
+ item.remove_meta(META_GDUNIT_ORPHAN)
+
+ set_item_icon_by_state(item)
+
+
+func set_state_running(item: TreeItem, is_running: bool) -> void:
+ if is_state_running(item):
+ return
+ if is_item_state(item, STATE.INITIAL):
+ item.set_custom_color(0, Color.DARK_GREEN)
+ item.set_custom_color(1, Color.DARK_GREEN)
+ item.set_meta(META_GDUNIT_STATE, STATE.RUNNING)
+ item.collapsed = false
+
+ if is_running:
+ item.set_icon(0, ICON_SPINNER)
+ else:
+ set_item_icon_by_state(item)
+ for child in item.get_children():
+ set_item_icon_by_state(child)
+
+ var parent := item.get_parent()
+ if parent != _tree_root:
+ set_state_running(parent, is_running)
+
+
+func set_state_succeded(item: TreeItem) -> void:
+ # Do not overwrite higher states
+ if is_state_error(item) or is_state_failed(item):
+ return
+ if item == _tree_root:
+ return
+ item.set_custom_color(0, Color.GREEN)
+ item.set_custom_color(1, Color.GREEN)
+ item.set_meta(META_GDUNIT_STATE, STATE.SUCCESS)
+ item.collapsed = GdUnitSettings.is_inspector_node_collapse()
+ set_item_icon_by_state(item)
+
+
+func set_state_flaky(item: TreeItem, event: GdUnitEvent) -> void:
+ # Do not overwrite higher states
+ if is_state_error(item):
+ return
+ var retry_count := event.statistic(GdUnitEvent.RETRY_COUNT)
+ item.set_meta(META_GDUNIT_STATE, STATE.FLAKY)
+ if retry_count > 1:
+ var item_text: String = item.get_meta(META_GDUNIT_NAME)
+ if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX):
+ var success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS)
+ item_text = "(%d/%d) %s" % [success_count, item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)]
+ item.set_text(0, "%s (%s retries)" % [item_text, retry_count])
+ item.set_custom_color(0, Color.GREEN_YELLOW)
+ item.set_custom_color(1, Color.GREEN_YELLOW)
+ item.collapsed = false
+ set_item_icon_by_state(item)
+
+
+func set_state_skipped(item: TreeItem) -> void:
+ item.set_meta(META_GDUNIT_STATE, STATE.SKIPPED)
+ item.set_text(1, "(skipped)")
+ item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT)
+ item.set_custom_color(0, Color.DARK_GRAY)
+ item.set_custom_color(1, Color.DARK_GRAY)
+ item.collapsed = false
+ set_item_icon_by_state(item)
+
+
+func set_state_warnings(item: TreeItem) -> void:
+ # Do not overwrite higher states
+ if is_state_error(item) or is_state_failed(item):
+ return
+ item.set_meta(META_GDUNIT_STATE, STATE.WARNING)
+ item.set_custom_color(0, Color.YELLOW)
+ item.set_custom_color(1, Color.YELLOW)
+ item.collapsed = false
+ set_item_icon_by_state(item)
+
+
+func set_state_failed(item: TreeItem, event: GdUnitEvent) -> void:
+ # Do not overwrite higher states
+ if is_state_error(item):
+ return
+ var retry_count := event.statistic(GdUnitEvent.RETRY_COUNT)
+ if retry_count > 1:
+ var item_text: String = item.get_meta(META_GDUNIT_NAME)
+ if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX):
+ var success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS)
+ item_text = "(%d/%d) %s" % [success_count, item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)]
+ item.set_text(0, "%s (%s retries)" % [item_text, retry_count])
+ item.set_meta(META_GDUNIT_STATE, STATE.FAILED)
+ item.set_custom_color(0, Color.LIGHT_BLUE)
+ item.set_custom_color(1, Color.LIGHT_BLUE)
+ item.collapsed = false
+ set_item_icon_by_state(item)
+
+
+func set_state_error(item: TreeItem) -> void:
+ item.set_meta(META_GDUNIT_STATE, STATE.ERROR)
+ item.set_custom_color(0, Color.ORANGE_RED)
+ item.set_custom_color(1, Color.ORANGE_RED)
+ set_item_icon_by_state(item)
+ item.collapsed = false
+
+
+func set_state_aborted(item: TreeItem) -> void:
+ item.set_meta(META_GDUNIT_STATE, STATE.ABORDED)
+ item.set_custom_color(0, Color.ORANGE_RED)
+ item.set_custom_color(1, Color.ORANGE_RED)
+ item.clear_custom_bg_color(0)
+ item.set_text(1, "(aborted)")
+ item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT)
+ set_item_icon_by_state(item)
+ item.collapsed = false
+
+
+func set_state_orphan(item: TreeItem, event: GdUnitEvent) -> void:
+ var orphan_count := event.statistic(GdUnitEvent.ORPHAN_NODES)
+ if orphan_count == 0:
+ return
+ if item.has_meta(META_GDUNIT_ORPHAN):
+ orphan_count += item.get_meta(META_GDUNIT_ORPHAN)
+ item.set_meta(META_GDUNIT_ORPHAN, orphan_count)
+ if item.get_meta(META_GDUNIT_STATE) != STATE.FAILED:
+ item.set_custom_color(0, Color.YELLOW)
+ item.set_custom_color(1, Color.YELLOW)
+ item.set_tooltip_text(0, "Total <%d> orphan nodes detected." % orphan_count)
+ set_item_icon_by_state(item)
+
+
+func update_state(item: TreeItem, event: GdUnitEvent, add_reports := true) -> void:
+ # we do not show the root
+ if item == null:
+ return
+
+ if event.is_skipped():
+ set_state_skipped(item)
+ elif event.is_success() and event.is_flaky():
+ set_state_flaky(item, event)
+ elif event.is_success():
+ set_state_succeded(item)
+ elif event.is_error():
+ set_state_error(item)
+ elif event.is_failed():
+ set_state_failed(item, event)
+ elif event.is_warning():
+ set_state_warnings(item)
+ if add_reports:
+ for report in event.reports():
+ add_report(item, report)
+ set_state_orphan(item, event)
+
+ var parent := item.get_parent()
+ if parent == null:
+ return
+
+ var item_state: int = item.get_meta(META_GDUNIT_STATE)
+ var parent_state: int = parent.get_meta(META_GDUNIT_STATE)
+ if item_state <= parent_state:
+ return
+ update_state(item.get_parent(), event, false)
+
+
+func add_report(item: TreeItem, report: GdUnitReport) -> void:
+ var reports: Array[GdUnitReport] = []
+ if item.has_meta(META_GDUNIT_REPORT):
+ reports = get_item_reports(item)
+ reports.append(report)
+ item.set_meta(META_GDUNIT_REPORT, reports)
+
+
+func abort_running(items:=_tree_root.get_children()) -> void:
+ for item in items:
+ if item.get_icon(0) == ICON_SPINNER:
+ set_state_aborted(item)
+ abort_running(item.get_children())
+
+
+func _on_select_next_item_by_state(item_state: int) -> TreeItem:
+ var current_selected := _tree.get_selected()
+ # If nothing is selected, the first error is selected or the next one in the vicinity of the current selection is found
+ current_selected = _find_first_item_by_state(_tree_root, item_state) if current_selected == null else _find_item_by_state(current_selected, item_state)
+ # If no next failure found, then we try to select first
+ if current_selected == null:
+ current_selected = _find_first_item_by_state(_tree_root, item_state)
+ return select_item(current_selected)
+
+
+func _on_select_previous_item_by_state(item_state: int) -> TreeItem:
+ var current_selected := _tree.get_selected()
+ # If nothing is selected, the first error is selected or the next one in the vicinity of the current selection is found
+ current_selected = _find_last_item_by_state(_tree_root, item_state) if current_selected == null else _find_item_by_state(current_selected, item_state, true)
+ # If no next failure found, then we try to select first last
+ if current_selected == null:
+ current_selected = _find_last_item_by_state(_tree_root, item_state)
+ return select_item(current_selected)
+
+
+func select_first_orphan() -> void:
+ for parent in _tree_root.get_children():
+ if not is_state_success(parent):
+ for item in parent.get_children():
+ if is_item_state_orphan(item):
+ parent.set_collapsed(false)
+ @warning_ignore("return_value_discarded")
+ select_item(item)
+ return
+
+
+func clear_reports() -> void:
+ for child in _report_list.get_children():
+ _report_list.remove_child(child)
+ child.queue_free()
+
+
+func show_failed_report(selected_item: TreeItem) -> void:
+ clear_reports()
+ if selected_item == null or not selected_item.has_meta(META_GDUNIT_REPORT):
+ return
+ # add new reports
+ for report in get_item_reports(selected_item):
+ var reportNode: RichTextLabel = _report_template.duplicate()
+ _report_list.add_child(reportNode)
+ reportNode.push_color(Color.DARK_TURQUOISE)
+ reportNode.append_text(report.to_string())
+ reportNode.pop()
+ reportNode.visible = true
+
+
+func update_test_suite(event: GdUnitEvent) -> void:
+ var item := _find_tree_item_by_test_suite(_tree_root, event.resource_path(), event.suite_name())
+ if not item:
+ push_error("[InspectorTreeMainPanel#update_test_suite] Internal Error: Can't find test suite item '{_suite_name}' for {_resource_path} ".format(event))
+ return
+ if event.type() == GdUnitEvent.TESTSUITE_AFTER:
+ update_item_elapsed_time_counter(item, event.elapsed_time())
+ update_state(item, event)
+ set_state_running(item, false)
+
+
+func update_test_case(event: GdUnitEvent) -> void:
+ var item := _find_tree_item_by_id(_tree_root, event.guid())
+ if not item:
+ #push_error("Internal Error: Can't find test id %s" % [event.guid()])
+ return
+ if event.type() == GdUnitEvent.TESTCASE_BEFORE:
+ set_state_running(item, true)
+ # force scrolling to current test case
+ _tree.scroll_to_item(item, true)
+ return
+
+ if event.type() == GdUnitEvent.TESTCASE_AFTER:
+ update_item_elapsed_time_counter(item, event.elapsed_time())
+ if event.is_success() or event.is_warning():
+ update_item_processed_counter(item)
+ update_state(item, event)
+ update_progress_counters(item)
+
+
+func create_item(parent: TreeItem, test: GdUnitTestCase, item_name: String, type: GdUnitType) -> TreeItem:
+ var item := _tree.create_item(parent)
+ item.collapsed = true
+ item.set_meta(META_GDUNIT_ORIGINAL_INDEX, item.get_index())
+ item.set_text(0, item_name)
+ match type:
+ GdUnitType.TEST_CASE:
+ item.set_meta(META_TEST_CASE, test)
+ GdUnitType.TEST_GROUP:
+ # We need to create a copy of the test record meta with a new uniqe guid
+ item.set_meta(META_TEST_CASE, GdUnitTestCase.from(test.suite_resource_path, test.source_file, test.line_number, test.test_name))
+ GdUnitType.TEST_SUITE:
+ # We need to create a copy of the test record meta with a new uniqe guid
+ item.set_meta(META_TEST_CASE, GdUnitTestCase.from(test.suite_resource_path, test.source_file, test.line_number, test.suite_name))
+
+ item.set_meta(META_GDUNIT_NAME, item_name)
+ set_state_initial(item, type)
+ update_item_total_counter(item)
+ return item
+
+
+func set_item_icon_by_state(item :TreeItem) -> void:
+ if item == _tree_root:
+ return
+ var state :STATE = item.get_meta(META_GDUNIT_STATE)
+ var is_orphan := is_item_state_orphan(item)
+ var resource_path := get_item_source_file(item)
+ item.set_icon(0, get_icon_by_file_type(resource_path, state, is_orphan))
+ if item.get_meta(META_GDUNIT_TYPE) == GdUnitType.FOLDER:
+ item.set_icon_modulate(0, Color.SKY_BLUE)
+
+
+func update_item_total_counter(item: TreeItem) -> void:
+ if item == null:
+ return
+
+ var child_count := get_total_child_count(item)
+ if child_count > 0:
+ item.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, child_count)
+ item.set_text(0, "(0/%d) %s" % [child_count, item.get_meta(META_GDUNIT_NAME)])
+
+ update_item_total_counter(item.get_parent())
+
+
+func get_total_child_count(item: TreeItem) -> int:
+ var total_count := 0
+ for child in item.get_children():
+ total_count += child.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX) if child.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX) else 1
+ return total_count
+
+
+func update_item_processed_counter(item: TreeItem, add_count := 1) -> void:
+ if item == _tree_root:
+ return
+
+ var success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS) + add_count
+ item.set_meta(META_GDUNIT_SUCCESS_TESTS, success_count)
+ if item.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX):
+ item.set_text(0, "(%d/%d) %s" % [success_count, item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX), item.get_meta(META_GDUNIT_NAME)])
+
+ update_item_processed_counter(item.get_parent(), add_count)
+
+
+func update_progress_counters(item: TreeItem) -> void:
+ var index: int = _tree_root.get_meta(META_GDUNIT_PROGRESS_INDEX) + 1
+ var total_test: int = _tree_root.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX)
+ var state: STATE = item.get_meta(META_GDUNIT_STATE)
+ test_counters_changed.emit(index, total_test, state)
+ _tree_root.set_meta(META_GDUNIT_PROGRESS_INDEX, index)
+
+
+func recalculate_counters(parent: TreeItem) -> void:
+ # Reset the counter first
+ if parent.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX):
+ parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0)
+ if parent.has_meta(META_GDUNIT_PROGRESS_INDEX):
+ parent.set_meta(META_GDUNIT_PROGRESS_INDEX, 0)
+ if parent.has_meta(META_GDUNIT_SUCCESS_TESTS):
+ parent.set_meta(META_GDUNIT_SUCCESS_TESTS, 0)
+
+ # Calculate new count based on children
+ var total_count := 0
+ var success_count := 0
+ var progress_index := 0
+
+ for child in parent.get_children():
+ if child.get_child_count() > 0:
+ # Recursively update child counters first
+ recalculate_counters(child)
+ # Add child's counters to parent
+ if child.has_meta(META_GDUNIT_PROGRESS_COUNT_MAX):
+ total_count += child.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX)
+ if child.has_meta(META_GDUNIT_SUCCESS_TESTS):
+ success_count += child.get_meta(META_GDUNIT_SUCCESS_TESTS)
+ if child.has_meta(META_GDUNIT_PROGRESS_INDEX):
+ progress_index += child.get_meta(META_GDUNIT_PROGRESS_INDEX)
+ elif is_test_case(child):
+ # Count individual test cases
+ total_count += 1
+ # Count completed tests
+ if is_state_success(child) or is_state_warning(child) or is_state_failed(child) or is_state_error(child):
+ progress_index += 1
+ if is_state_success(child) or is_state_warning(child):
+ success_count += 1
+
+ # Update the counters
+ if total_count > 0:
+ parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, total_count)
+ parent.set_meta(META_GDUNIT_PROGRESS_INDEX, progress_index)
+ parent.set_meta(META_GDUNIT_SUCCESS_TESTS, success_count)
+
+ # Update the display text
+ parent.set_text(0, "(%d/%d) %s" % [success_count, total_count, parent.get_meta(META_GDUNIT_NAME)])
+
+
+func update_item_elapsed_time_counter(item: TreeItem, time: int) -> void:
+ item.set_text(1, "%s" % LocalTime.elapsed(time))
+ item.set_text_alignment(1, HORIZONTAL_ALIGNMENT_RIGHT)
+ item.set_meta(META_GDUNIT_EXECUTION_TIME, time)
+
+ var parent := item.get_parent()
+ if parent == _tree_root:
+ return
+ var elapsed_time :int = parent.get_meta(META_GDUNIT_EXECUTION_TIME) + time
+ var type :GdUnitType = item.get_meta(META_GDUNIT_TYPE)
+ match type:
+ GdUnitType.TEST_CASE:
+ return
+ GdUnitType.TEST_SUITE:
+ update_item_elapsed_time_counter(parent, elapsed_time)
+ #GdUnitType.FOLDER:
+ # update_item_elapsed_time_counter(parent, elapsed_time)
+
+
+func get_icon_by_file_type(path: String, state: STATE, orphans: bool) -> Texture2D:
+ if path.get_extension() == "gd":
+ match state:
+ STATE.INITIAL:
+ return ICON_GDSCRIPT_TEST_DEFAULT
+ STATE.SUCCESS:
+ return ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_SUCCESS
+ STATE.ERROR:
+ return ICON_GDSCRIPT_TEST_ERRORS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_ERROR
+ STATE.FAILED:
+ return ICON_GDSCRIPT_TEST_FAILED_ORPHAN if orphans else ICON_GDSCRIPT_TEST_FAILED
+ STATE.WARNING:
+ return ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_DEFAULT
+ STATE.FLAKY:
+ return ICON_GDSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_GDSCRIPT_TEST_FLAKY
+ _:
+ return ICON_GDSCRIPT_TEST_DEFAULT
+ if path.get_extension() == "cs":
+ match state:
+ STATE.INITIAL:
+ return ICON_CSSCRIPT_TEST_DEFAULT
+ STATE.SUCCESS:
+ return ICON_CSSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_CSSCRIPT_TEST_SUCCESS
+ STATE.ERROR:
+ return ICON_CSSCRIPT_TEST_ERRORS_ORPHAN if orphans else ICON_CSSCRIPT_TEST_ERROR
+ STATE.FAILED:
+ return ICON_CSSCRIPT_TEST_FAILED_ORPHAN if orphans else ICON_CSSCRIPT_TEST_FAILED
+ STATE.WARNING:
+ return ICON_CSSCRIPT_TEST_SUCCESS_ORPHAN if orphans else ICON_CSSCRIPT_TEST_DEFAULT
+ _:
+ return ICON_CSSCRIPT_TEST_DEFAULT
+ match state:
+ STATE.INITIAL:
+ return ICON_FOLDER
+ STATE.ERROR:
+ return ICON_FOLDER
+ STATE.FAILED:
+ return ICON_FOLDER
+ _:
+ return ICON_FOLDER
+
+
+func on_test_case_discover_added(test_case: GdUnitTestCase) -> void:
+ var test_root_folder := GdUnitSettings.test_root_folder().replace("res://", "")
+ var fully_qualified_name := test_case.fully_qualified_name.trim_suffix(test_case.display_name)
+ var parts := fully_qualified_name.split(".", false)
+ parts.append(test_case.display_name)
+ # Skip tree structure until test root folder
+ var index := parts.find(test_root_folder)
+ if index != -1:
+ parts = parts.slice(index+1)
+
+ match _current_tree_view_mode:
+ GdUnitInspectorTreeConstants.TREE_VIEW_MODE.FLAT:
+ create_items_tree_mode_flat(test_case, parts)
+ GdUnitInspectorTreeConstants.TREE_VIEW_MODE.TREE:
+ create_items_tree_mode_tree(test_case, parts)
+
+
+func create_items_tree_mode_tree(test_case: GdUnitTestCase, parts: PackedStringArray) -> void:
+ var parent := _tree_root
+ var is_suite_assigned := false
+ var suite_name := test_case.suite_name.split(".")[-1]
+ for item_name in parts:
+ var next := _find_tree_item(parent, item_name)
+ if next != null:
+ parent = next
+ continue
+
+ if not is_suite_assigned and suite_name == item_name:
+ next = create_item(parent, test_case, item_name, GdUnitType.TEST_SUITE)
+ is_suite_assigned = true
+ elif item_name == test_case.display_name:
+ next = create_item(parent, test_case, item_name, GdUnitType.TEST_CASE)
+ # On grouped tests (parameterized tests)
+ elif item_name == test_case.test_name:
+ next = create_item(parent, test_case, item_name, GdUnitType.TEST_GROUP)
+ else:
+ next = create_item(parent, test_case, item_name, GdUnitType.FOLDER)
+ parent = next
+
+
+func create_items_tree_mode_flat(test_case: GdUnitTestCase, parts: PackedStringArray) -> void:
+ # All parts except the last two (suite name and test name/display name)
+ var slice_index := -2 if parts[-1] == test_case.test_name else -3
+ var path_parts := parts.slice(0, slice_index)
+ var folder_path := "/".join(path_parts)
+
+ # Find or create flat folder
+ var folder_item: TreeItem
+ if folder_path.is_empty():
+ folder_item = _tree_root
+ else:
+ folder_item = _find_tree_item(_tree_root, folder_path)
+ if folder_item == null:
+ folder_item = create_item(_tree_root, test_case, folder_path, GdUnitType.FOLDER)
+
+ # Find suite under the flat folder (second to last part)
+ var suite_item := _find_tree_item(folder_item, test_case.suite_name)
+ if suite_item == null:
+ suite_item = create_item(folder_item, test_case, test_case.suite_name, GdUnitType.TEST_SUITE)
+
+ # Add test case or group under the suite
+ if test_case.test_name != test_case.display_name:
+ # It's a parameterized test group
+ var group_item := _find_tree_item(suite_item, test_case.test_name)
+ if group_item == null:
+ group_item = create_item(suite_item, test_case, test_case.test_name, GdUnitType.TEST_GROUP)
+ create_item(group_item, test_case, test_case.display_name, GdUnitType.TEST_CASE)
+ else:
+ create_item(suite_item, test_case, test_case.display_name, GdUnitType.TEST_CASE)
+
+
+func on_test_case_discover_deleted(test_case: GdUnitTestCase) -> void:
+ var item := _find_tree_item_by_id(_tree_root, test_case.guid)
+ if item != null:
+ var parent := item.get_parent()
+ parent.remove_child(item)
+
+ # update the cached counters
+ var item_success_count: int = item.get_meta(META_GDUNIT_SUCCESS_TESTS)
+ var item_total_test_count: int = item.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0)
+ var total_test_count: int = parent.get_meta(META_GDUNIT_PROGRESS_COUNT_MAX, 0)
+ parent.set_meta(META_GDUNIT_PROGRESS_COUNT_MAX, total_test_count-item_total_test_count)
+
+ # propagate counter update to all parents
+ update_item_total_counter(parent)
+ update_item_processed_counter(parent, -item_success_count)
+
+
+func on_test_case_discover_modified(test_case: GdUnitTestCase) -> void:
+ var item := _find_tree_item_by_id(_tree_root, test_case.guid)
+ if item != null:
+ item.set_meta(META_TEST_CASE, test_case)
+ item.set_text(0, test_case.display_name)
+ item.set_meta(META_GDUNIT_NAME, test_case.display_name)
+
+
+func get_item_reports(item: TreeItem) -> Array[GdUnitReport]:
+ return item.get_meta(META_GDUNIT_REPORT)
+
+
+func get_item_test_line_number(item: TreeItem) -> int:
+ if item == null or not item.has_meta(META_TEST_CASE):
+ return -1
+
+ var test_case: GdUnitTestCase = item.get_meta(META_TEST_CASE)
+ return test_case.line_number
+
+
+func get_item_source_file(item: TreeItem) -> String:
+ if item == null or not item.has_meta(META_TEST_CASE):
+ return ""
+
+ var test_case: GdUnitTestCase = item.get_meta(META_TEST_CASE)
+ return test_case.source_file
+
+
+func get_item_type(item: TreeItem) -> GdUnitType:
+ if item == null or not item.has_meta(META_GDUNIT_TYPE):
+ return GdUnitType.FOLDER
+ return item.get_meta(META_GDUNIT_TYPE)
+
+
+func _dump_tree_as_json(dump_name: String) -> void:
+ var dict := _to_json(_tree_root)
+ var file := FileAccess.open("res://%s.json" % dump_name, FileAccess.WRITE)
+ file.store_string(JSON.stringify(dict, "\t"))
+
+
+func _to_json(parent :TreeItem) -> Dictionary:
+ var item_as_dict := GdObjects.obj2dict(parent)
+ item_as_dict["TreeItem"]["childrens"] = parent.get_children().map(func(item: TreeItem) -> Dictionary:
+ return _to_json(item))
+ return item_as_dict
+
+
+func extract_resource_path(event: GdUnitEvent) -> String:
+ return ProjectSettings.localize_path(event.resource_path())
+
+
+func collect_test_cases(item: TreeItem, tests: Array[GdUnitTestCase] = []) -> Array[GdUnitTestCase]:
+ for next in item.get_children():
+ collect_test_cases(next, tests)
+
+ if is_test_case(item):
+ var test: GdUnitTestCase = item.get_meta(META_TEST_CASE)
+ if not tests.has(test):
+ tests.append(test)
+
+ return tests
+
+
+func test_session_start() -> void:
+ _context_menu.disable_items()
+ reset_tree_state(_tree_root)
+ clear_reports()
+
+
+func test_session_stop() -> void:
+ _context_menu.enable_items()
+ abort_running()
+ sort_tree_items(_tree_root)
+ # wait until the tree redraw
+ await get_tree().process_frame
+ var failure_item := _find_first_item_by_state(_tree_root, STATE.FAILED)
+ select_item( failure_item if failure_item else _current_selected_item)
+
+
+################################################################################
+# Tree signal receiver
+################################################################################
+func _on_tree_item_mouse_selected(mouse_position: Vector2, mouse_button_index: int) -> void:
+ if mouse_button_index == MOUSE_BUTTON_RIGHT:
+ _context_menu.position = get_screen_position() + mouse_position
+ _context_menu.popup()
+
+
+func _on_Tree_item_selected() -> void:
+ _current_selected_item = _tree.get_selected()
+ show_failed_report(_current_selected_item)
+ tree_item_selected.emit(_current_selected_item)
+
+
+# Opens the test suite
+func _on_Tree_item_activated() -> void:
+ var selected_item := _tree.get_selected()
+ var line_number := get_item_test_line_number(selected_item)
+ if line_number != -1:
+ var script_path := ProjectSettings.localize_path(get_item_source_file(selected_item))
+ var resource: Script = load(script_path)
+
+ if selected_item.has_meta(META_GDUNIT_REPORT):
+ var reports := get_item_reports(selected_item)
+ var report_line_number := reports[0].line_number()
+ # if number -1 we use original stored line number of the test case
+ # in non debug mode the line number is not available
+ if report_line_number != -1:
+ line_number = report_line_number
+
+ EditorInterface.get_file_system_dock().navigate_to_path(script_path)
+ EditorInterface.edit_script(resource, line_number)
+ elif selected_item.get_meta(META_GDUNIT_TYPE) == GdUnitType.FOLDER:
+ # Toggle collapse if dir
+ selected_item.collapsed = not selected_item.collapsed
+
+
+################################################################################
+# external signal receiver
+################################################################################
+
+func _on_gdunit_event(event: GdUnitEvent) -> void:
+ match event.type():
+ GdUnitEvent.DISCOVER_START:
+ _tree_root.visible = false
+ _discover_hint.visible = true
+ init_tree()
+
+ GdUnitEvent.DISCOVER_END:
+ sort_tree_items(_tree_root)
+ select_item(_tree_root.get_first_child())
+ _discover_hint.visible = false
+ _tree_root.visible = true
+ #_dump_tree_as_json("tree_example_discovered")
+
+ GdUnitEvent.TESTCASE_BEFORE:
+ update_test_case(event)
+
+ GdUnitEvent.TESTCASE_AFTER:
+ update_test_case(event)
+
+ GdUnitEvent.TESTSUITE_BEFORE:
+ update_test_suite(event)
+
+ GdUnitEvent.TESTSUITE_AFTER:
+ update_test_suite(event)
+
+ GdUnitEvent.SESSION_START:
+ test_session_start()
+
+ GdUnitEvent.SESSION_CLOSE:
+ await test_session_stop()
+
+
+func _on_settings_changed(property :GdUnitProperty) -> void:
+ match property.name():
+ GdUnitSettings.INSPECTOR_TREE_SORT_MODE:
+ sort_tree_items(_tree_root)
+ #_dump_tree_as_json("tree_sorted_by_%s" % GdUnitInspectorTreeConstants.SORT_MODE.keys()[property.value()])
+
+ GdUnitSettings.INSPECTOR_TREE_VIEW_MODE:
+ restructure_tree(_tree_root, GdUnitSettings.get_inspector_tree_view_mode())
diff --git a/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd.uid b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd.uid
new file mode 100644
index 0000000..587b139
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd.uid
@@ -0,0 +1 @@
+uid://m0w5xmvdst0k
diff --git a/addons/gdUnit4/src/ui/parts/InspectorTreePanel.tscn b/addons/gdUnit4/src/ui/parts/InspectorTreePanel.tscn
new file mode 100644
index 0000000..2754f74
--- /dev/null
+++ b/addons/gdUnit4/src/ui/parts/InspectorTreePanel.tscn
@@ -0,0 +1,240 @@
+[gd_scene format=3 uid="uid://bqfpidewtpeg0"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/parts/InspectorTreeMainPanel.gd" id="1"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/menu/GdUnitInspectorContextMenu.tscn" id="2_o6s0p"]
+
+[sub_resource type="DPITexture" id="DPITexture_miuuy"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M4 12a1 1 0 0 0 1.555.832l6-4a1 1 0 0 0 0-1.664l-6-4A1 1 0 0 0 4 4z\"/></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_ern2r"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"M3 3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm4.975 0A1 1 0 0 0 7 3.998v8a1 1 0 0 0 1.625.781l5-4a1 1 0 0 0 0-1.56l-5-4A1 1 0 0 0 7.975 3z\"/></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_qdci2"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"m8 9.669-3.536 2.583H7v2.537h2v-2.537h2.536zm0-3.314L4.464 3.772H7V1.235h2v2.537h2.536zm-7.296.73h14.591v1.831H.704z\"/></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_hed0i"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><path fill=\"#e0e0e0\" d=\"m8 16-3.5-2.5H7V11h2v2.5h2.5zM8 0 4.5 2.5H7V5h2V2.5h2.5zM1 7h14v2H1z\"/></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_8v04w"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path d=\"M9 1v3.055a4 4 0 0 1 1.027.426l2.159-2.159A7 7 0 0 0 9 1z\"/><path fill-opacity=\".2\" d=\"M7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.08zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.815zm11.2 0-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM1.08 9a7 7 0 0 0 1.32 3.186l2.158-2.159A4 4 0 0 1 4.13 9H1.08zm10.786 0a4 4 0 0 1-.425 1.027l2.158 2.159A7 7 0 0 0 14.921 9h-3.055zm-5.894 2.441L3.814 13.6a7 7 0 0 0 3.185 1.322v-3.055a4 4 0 0 1-1.027-.426zm4.055 0a4 4 0 0 1-1.028.428v3.05a7 7 0 0 0 3.186-1.32l-2.158-2.158z\"/></g></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_arwmg"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.079zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.814zM1.08 9a7 7 0 0 0 1.32 3.185l2.159-2.158A4 4 0 0 1 4.131 9H1.08zm10.787 0a4 4 0 0 1-.426 1.027l2.159 2.158A7 7 0 0 0 14.922 9h-3.055zm-5.894 2.441L3.814 13.6A7 7 0 0 0 7 14.922v-3.055a4 4 0 0 1-1.027-.426zm4.054 0A4 4 0 0 1 9 11.869v3.05a7 7 0 0 0 3.186-1.32l-2.159-2.158z\"/><path d=\"m13.6 3.8-2.158 2.158a4 4 0 0 1 .428 1.028h3.05A7 7 0 0 0 13.6 3.8z\"/></g></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_rqglq"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.079zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.814zm11.2 0-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM1.08 9a7 7 0 0 0 1.32 3.185l2.158-2.158A4 4 0 0 1 4.13 9H1.08zm4.892 2.441L3.814 13.6a7 7 0 0 0 3.185 1.322v-3.055a4 4 0 0 1-1.027-.426zm4.055 0a4 4 0 0 1-1.028.428v3.05a7 7 0 0 0 3.186-1.32l-2.158-2.158z\"/><path d=\"M11.867 9a4 4 0 0 1-.426 1.027l2.158 2.159A7 7 0 0 0 14.922 9h-3.055z\"/></g></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_ltb1l"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.079zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.814zm11.2 0-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM1.08 9a7 7 0 0 0 1.32 3.185l2.158-2.158A4 4 0 0 1 4.13 9H1.08zm10.786 0a4 4 0 0 1-.425 1.027l2.158 2.158A7 7 0 0 0 14.921 9h-3.055zm-5.894 2.441L3.814 13.6a7 7 0 0 0 3.185 1.322v-3.055a4 4 0 0 1-1.027-.426z\"/><path d=\"M10.027 11.4A4 4 0 0 1 9 11.828v3.05a7 7 0 0 0 3.185-1.32z\"/></g></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_2lq8w"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.079zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.814zm11.2 0-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM1.08 9a7 7 0 0 0 1.32 3.185l2.158-2.158A4 4 0 0 1 4.13 9H1.08zm10.786 0a4 4 0 0 1-.425 1.027l2.158 2.158A7 7 0 0 0 14.921 9h-3.055zm-1.84 2.441a4 4 0 0 1-1.027.428v3.05a7 7 0 0 0 3.186-1.32l-2.158-2.158z\"/><path d=\"m5.973 11.4-2.159 2.158A7 7 0 0 0 7 14.88v-3.054a4 4 0 0 1-1.027-.426z\"/></g></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_kwwmp"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.079zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.814zm11.2 0-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM11.867 9a4 4 0 0 1-.426 1.027l2.158 2.158A7 7 0 0 0 14.922 9h-3.055zm-5.895 2.441L3.814 13.6A7 7 0 0 0 7 14.922v-3.055a4 4 0 0 1-1.028-.426zm4.055 0A4 4 0 0 1 9 11.869v3.05a7 7 0 0 0 3.185-1.32l-2.158-2.158z\"/><path d=\"M1.08 9a7 7 0 0 0 1.32 3.186l2.159-2.159A4 4 0 0 1 4.131 9H1.08z\"/></g></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_t1rin"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM7 1.08A7 7 0 0 0 3.814 2.4l2.159 2.159A4 4 0 0 1 7 4.13V1.079zm6.6 2.734-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM1.08 9a7 7 0 0 0 1.32 3.185l2.158-2.158A4 4 0 0 1 4.13 9H1.08zm10.787 0a4 4 0 0 1-.426 1.027l2.158 2.158A7 7 0 0 0 14.921 9h-3.054zm-5.895 2.441L3.814 13.6a7 7 0 0 0 3.185 1.322v-3.055a4 4 0 0 1-1.027-.426zm4.055 0A4 4 0 0 1 9 11.869v3.05a7 7 0 0 0 3.185-1.32l-2.158-2.158z\"/><path d=\"M2.4 3.8a7 7 0 0 0-1.322 3.186h3.055a4 4 0 0 1 .426-1.028L2.4 3.8z\"/></g></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="DPITexture" id="DPITexture_pi1kv"]
+_source = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\"><g fill=\"#e0e0e0\"><path fill-opacity=\".2\" d=\"M9 1.078v3.055a4 4 0 0 1 1.027.426L12.186 2.4A7 7 0 0 0 9 1.078zM2.4 3.814A7 7 0 0 0 1.078 7h3.055a4 4 0 0 1 .426-1.027L2.4 3.814zm11.2 0-2.159 2.159A4 4 0 0 1 11.869 7h3.05a7 7 0 0 0-1.32-3.186zM1.08 9a7 7 0 0 0 1.32 3.185l2.158-2.158A4 4 0 0 1 4.13 9H1.08zm10.786 0a4 4 0 0 1-.425 1.027l2.158 2.158A7 7 0 0 0 14.921 9h-3.055zm-5.894 2.441L3.814 13.6a7 7 0 0 0 3.185 1.322v-3.055a4 4 0 0 1-1.027-.426zm4.055 0a4 4 0 0 1-1.028.428v3.05a7 7 0 0 0 3.186-1.32l-2.158-2.158z\"/><path d=\"M7 1a7 7 0 0 0-3.186 1.32l2.159 2.159A4 4 0 0 1 7 4.05z\"/></g></svg>
+"
+saturation = 2.0
+color_map = {
+Color(1, 0.37254903, 0.37254903, 1): Color(1, 0.47, 0.42, 1),
+Color(0.37254903, 1, 0.5921569, 1): Color(0.45, 0.95, 0.5, 1),
+Color(1, 0.8666667, 0.39607844, 1): Color(0.83, 0.78, 0.62, 1)
+}
+
+[sub_resource type="AnimatedTexture" id="AnimatedTexture_eor1x"]
+frames = 8
+speed_scale = 2.5
+frame_0/texture = SubResource("DPITexture_8v04w")
+frame_0/duration = 0.2
+frame_1/texture = SubResource("DPITexture_arwmg")
+frame_1/duration = 0.2
+frame_2/texture = SubResource("DPITexture_rqglq")
+frame_2/duration = 0.2
+frame_3/texture = SubResource("DPITexture_ltb1l")
+frame_3/duration = 0.2
+frame_4/texture = SubResource("DPITexture_2lq8w")
+frame_4/duration = 0.2
+frame_5/texture = SubResource("DPITexture_kwwmp")
+frame_5/duration = 0.2
+frame_6/texture = SubResource("DPITexture_t1rin")
+frame_6/duration = 0.2
+frame_7/texture = SubResource("DPITexture_pi1kv")
+frame_7/duration = 0.2
+
+[node name="MainPanel" type="VSplitContainer" unique_id=1044515559]
+use_parent_material = true
+clip_contents = true
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_right = -924.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+split_offsets = PackedInt32Array(200)
+split_offset = 200
+script = ExtResource("1")
+
+[node name="contextMenu" parent="." unique_id=73764904 instance=ExtResource("2_o6s0p")]
+visible = false
+item_0/icon = SubResource("DPITexture_miuuy")
+item_1/icon = SubResource("DPITexture_ern2r")
+item_2/text = "Run Tests Until Fail"
+item_2/icon = SubResource("DPITexture_miuuy")
+item_4/icon = SubResource("DPITexture_qdci2")
+item_5/icon = SubResource("DPITexture_hed0i")
+
+[node name="Panel" type="PanelContainer" parent="." unique_id=641912870]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="Tree" type="Tree" parent="Panel" unique_id=741772884]
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+theme_override_constants/icon_max_width = 16
+columns = 2
+allow_reselect = true
+allow_rmb_select = true
+hide_root = true
+select_mode = 1
+
+[node name="discover_hint" type="HBoxContainer" parent="Panel" unique_id=555683311]
+unique_name_in_owner = true
+visible = false
+use_parent_material = true
+layout_mode = 2
+alignment = 1
+
+[node name="spinner" type="Button" parent="Panel/discover_hint" unique_id=200472082]
+unique_name_in_owner = true
+clip_contents = true
+custom_minimum_size = Vector2(64, 64)
+layout_mode = 2
+size_flags_stretch_ratio = 1.94
+disabled = true
+button_mask = 0
+text = "Discover Tests"
+icon = SubResource("AnimatedTexture_eor1x")
+flat = true
+alignment = 2
+
+[node name="report" type="PanelContainer" parent="." unique_id=503703488]
+clip_contents = true
+layout_mode = 2
+size_flags_horizontal = 11
+size_flags_vertical = 11
+
+[node name="report_template" type="RichTextLabel" parent="report" unique_id=817912164]
+auto_translate_mode = 2
+use_parent_material = true
+clip_contents = false
+layout_mode = 2
+size_flags_horizontal = 3
+localize_numeral_system = false
+focus_mode = 2
+bbcode_enabled = true
+fit_content = true
+selection_enabled = true
+
+[node name="ScrollContainer" type="ScrollContainer" parent="report" unique_id=1479075289]
+use_parent_material = true
+custom_minimum_size = Vector2(0, 80)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 11
+
+[node name="list" type="VBoxContainer" parent="report/ScrollContainer" unique_id=1104634565]
+clip_contents = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[connection signal="item_activated" from="Panel/Tree" to="." method="_on_Tree_item_activated"]
+[connection signal="item_mouse_selected" from="Panel/Tree" to="." method="_on_tree_item_mouse_selected"]
+[connection signal="item_selected" from="Panel/Tree" to="." method="_on_Tree_item_selected"]
diff --git a/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd
new file mode 100644
index 0000000..b5cc837
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd
@@ -0,0 +1,56 @@
+@tool
+class_name GdUnitInputCapture
+extends Control
+
+signal input_completed(input_event: InputEventKey)
+
+
+var _tween: Tween
+var _input_event: InputEventKey
+
+
+func _ready() -> void:
+ reset()
+ self_modulate = Color.WHITE
+ _tween = create_tween()
+ @warning_ignore("return_value_discarded")
+ _tween.set_loops()
+ @warning_ignore("return_value_discarded")
+ _tween.tween_property(%Label, "self_modulate", Color(1, 1, 1, .8), 1.0).from_current().set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_IN_OUT)
+
+
+func reset() -> void:
+ _input_event = InputEventKey.new()
+
+
+func _input(event: InputEvent) -> void:
+ if not is_visible_in_tree():
+ return
+ if event is InputEventKey and event.is_pressed() and not event.is_echo():
+ var _event := event as InputEventKey
+ match _event.keycode:
+ KEY_CTRL:
+ _input_event.ctrl_pressed = true
+ KEY_SHIFT:
+ _input_event.shift_pressed = true
+ KEY_ALT:
+ _input_event.alt_pressed = true
+ KEY_META:
+ _input_event.meta_pressed = true
+ _:
+ _input_event.keycode = _event.keycode
+ _apply_input_modifiers(_event)
+ accept_event()
+
+ if event is InputEventKey and not event.is_pressed():
+ input_completed.emit(_input_event)
+ hide()
+
+
+func _apply_input_modifiers(event: InputEvent) -> void:
+ if event is InputEventWithModifiers:
+ var _event := event as InputEventWithModifiers
+ _input_event.meta_pressed = _event.meta_pressed or _input_event.meta_pressed
+ _input_event.alt_pressed = _event.alt_pressed or _input_event.alt_pressed
+ _input_event.shift_pressed = _event.shift_pressed or _input_event.shift_pressed
+ _input_event.ctrl_pressed = _event.ctrl_pressed or _input_event.ctrl_pressed
diff --git a/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd.uid b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd.uid
new file mode 100644
index 0000000..4b9116e
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd.uid
@@ -0,0 +1 @@
+uid://b6rgevbcd0k5n
diff --git a/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn
new file mode 100644
index 0000000..e656bf8
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn
@@ -0,0 +1,36 @@
+[gd_scene format=3 uid="uid://pmnkxrhglak5"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/settings/GdUnitInputCapture.gd" id="1_gki1u"]
+
+[node name="GdUnitInputMapper" type="Control"]
+modulate = Color(0.929099, 0.929099, 0.929099, 0.936189)
+top_level = true
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+script = ExtResource("1_gki1u")
+
+[node name="Label" type="Label" parent="."]
+unique_name_in_owner = true
+self_modulate = Color(0.401913, 0.401913, 0.401913, 0.461723)
+top_level = true
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -60.5
+offset_top = -19.5
+offset_right = 60.5
+offset_bottom = 19.5
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_colors/font_color = Color(1, 1, 1, 1)
+theme_override_font_sizes/font_size = 26
+text = "Press keys for shortcut"
diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd
new file mode 100644
index 0000000..14a8bd5
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd
@@ -0,0 +1,327 @@
+@tool
+extends Window
+
+const EAXAMPLE_URL := "https://github.com/MikeSchulze/gdUnit4-examples/archive/refs/heads/master.zip"
+const GdUnitTools := preload ("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+const GdUnitUpdateClient = preload ("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd")
+
+@onready var _update_client: GdUnitUpdateClient = $GdUnitUpdateClient
+@onready var _version_label: RichTextLabel = %version
+@onready var _btn_install: Button = %btn_install_examples
+@onready var _progress_bar: ProgressBar = %ProgressBar
+@onready var _progress_text: Label = %progress_lbl
+@onready var _properties_template: Control = $property_template
+@onready var _properties_common: Control = % "common-content"
+@onready var _properties_ui: Control = % "ui-content"
+@onready var _properties_shortcuts: Control = % "shortcut-content"
+@onready var _properties_report: Control = % "report-content"
+@onready var _input_capture: GdUnitInputCapture = %GdUnitInputCapture
+@onready var _property_error: Window = % "propertyError"
+@onready var _tab_container: TabContainer = %Properties
+@onready var _update_tab: Control = %Update
+
+var _font_size: float
+
+
+func _ready() -> void:
+ set_name("GdUnitSettingsDialog")
+ # initialize for testing
+ if not Engine.is_editor_hint():
+ GdUnitSettings.setup()
+ GdUnit4Version.init_version_label(_version_label)
+ _font_size = GdUnitFonts.init_fonts(_version_label)
+ setup_properties(_properties_common, GdUnitSettings.COMMON_SETTINGS)
+ setup_properties(_properties_ui, GdUnitSettings.UI_SETTINGS)
+ setup_properties(_properties_report, GdUnitSettings.REPORT_SETTINGS)
+ setup_properties(_properties_shortcuts, GdUnitSettings.SHORTCUT_SETTINGS)
+ check_for_update()
+
+
+func _sort_by_key(left: GdUnitProperty, right: GdUnitProperty) -> bool:
+ return left.name() < right.name()
+
+
+func setup_properties(properties_parent: Control, property_category: String) -> void:
+ # Do remove first potential previous added properties (could be happened when the dlg is opened at twice)
+ for child in properties_parent.get_children():
+ properties_parent.remove_child(child)
+
+ var category_properties := GdUnitSettings.list_settings(property_category)
+ # sort by key
+ category_properties.sort_custom(_sort_by_key)
+ var theme_ := Theme.new()
+ theme_.set_constant("h_separation", "GridContainer", 12)
+ var last_category := "!"
+ var min_size_overall := 0.0
+ var labels := []
+ var inputs := []
+ var info_labels := []
+ var grid: GridContainer = null
+ for p in category_properties:
+ var min_size_ := 0.0
+ var property: GdUnitProperty = p
+ var current_category := property.category()
+ if not grid or current_category != last_category:
+ grid = GridContainer.new()
+ grid.columns = 4
+ grid.theme = theme_
+
+ var sub_category: Control = _properties_template.get_child(3).duplicate()
+ var category_label: Label = sub_category.get_child(0)
+ category_label.text = current_category.capitalize()
+ sub_category.custom_minimum_size.y = _font_size + 16
+ properties_parent.add_child(sub_category)
+ properties_parent.add_child(grid)
+ last_category = current_category
+ # property name
+ var label: Label = _properties_template.get_child(0).duplicate()
+ label.text = _to_human_readable(property.name())
+ labels.append(label)
+ grid.add_child(label)
+
+ # property reset btn
+ var reset_btn: Button = _properties_template.get_child(1).duplicate()
+ reset_btn.icon = _get_btn_icon("Reload")
+ reset_btn.disabled = property.value() == property.default()
+ grid.add_child(reset_btn)
+
+ # property type specific input element
+ var input: Node = _create_input_element(property, reset_btn)
+ inputs.append(input)
+ grid.add_child(input)
+ @warning_ignore("return_value_discarded")
+ reset_btn.pressed.connect(_on_btn_property_reset_pressed.bind(property, input, reset_btn))
+ # property help text
+ var info: Label = _properties_template.get_child(2).duplicate()
+ info.text = property.help()
+ info_labels.append(info)
+ grid.add_child(info)
+ if min_size_overall < min_size_:
+ min_size_overall = min_size_
+
+ for controls: Array in [labels, inputs, info_labels]:
+ var _size: float = controls.map(func(c: Control) -> float: return c.size.x).max()
+ min_size_overall += _size
+ for control: Control in controls:
+ control.custom_minimum_size.x = _size
+ properties_parent.custom_minimum_size.x = min_size_overall
+
+
+func _create_input_element(property: GdUnitProperty, reset_btn: Button) -> Node:
+ if property.is_selectable_value():
+ var options := OptionButton.new()
+ options.alignment = HORIZONTAL_ALIGNMENT_CENTER
+ for value in property.value_set():
+ options.add_item(value)
+ options.item_selected.connect(_on_option_selected.bind(property, reset_btn))
+ options.select(property.int_value())
+ return options
+ if property.type() == TYPE_BOOL:
+ var check_btn := CheckButton.new()
+ check_btn.toggled.connect(_on_property_text_changed.bind(property, reset_btn))
+ check_btn.button_pressed = property.value()
+ return check_btn
+ if property.type() in [TYPE_INT, TYPE_STRING]:
+ var input := LineEdit.new()
+ input.text_changed.connect(_on_property_text_changed.bind(property, reset_btn))
+ input.set_context_menu_enabled(false)
+ input.set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER)
+ input.set_expand_to_text_length_enabled(true)
+ input.text = str(property.value())
+ return input
+ if property.type() == TYPE_PACKED_INT32_ARRAY:
+ var key_input_button := Button.new()
+ var value:PackedInt32Array = property.value()
+ key_input_button.text = to_shortcut(value)
+ key_input_button.pressed.connect(_on_shortcut_change.bind(key_input_button, property, reset_btn))
+ return key_input_button
+ return Control.new()
+
+
+func to_shortcut(keys: PackedInt32Array) -> String:
+ var input_event := InputEventKey.new()
+ for key in keys:
+ match key:
+ KEY_CTRL: input_event.ctrl_pressed = true
+ KEY_SHIFT: input_event.shift_pressed = true
+ KEY_ALT: input_event.alt_pressed = true
+ KEY_META: input_event.meta_pressed = true
+ _:
+ input_event.keycode = key as Key
+ return input_event.as_text()
+
+
+func to_keys(input_event: InputEventKey) -> PackedInt32Array:
+ var keys := PackedInt32Array()
+ if input_event.ctrl_pressed:
+ keys.append(KEY_CTRL)
+ if input_event.shift_pressed:
+ keys.append(KEY_SHIFT)
+ if input_event.alt_pressed:
+ keys.append(KEY_ALT)
+ if input_event.meta_pressed:
+ keys.append(KEY_META)
+ keys.append(input_event.keycode)
+ return keys
+
+
+func _to_human_readable(value: String) -> String:
+ return value.split("/")[-1].capitalize()
+
+
+func _get_btn_icon(p_name: String) -> Texture2D:
+ if not Engine.is_editor_hint():
+ var placeholder := PlaceholderTexture2D.new()
+ placeholder.size = Vector2(8, 8)
+ return placeholder
+ return GdUnitUiTools.get_icon(p_name)
+
+
+func _install_examples() -> void:
+ _init_progress(5)
+ update_progress("Downloading examples")
+ await get_tree().process_frame
+ var tmp_path := GdUnitFileAccess.create_temp_dir("download")
+ var zip_file := tmp_path + "/examples.zip"
+ var response: GdUnitUpdateClient.HttpResponse = await _update_client.request_zip_package(EAXAMPLE_URL, zip_file)
+ if response.status() != 200:
+ push_warning("Examples cannot be retrieved from GitHub! \n Error code: %d : %s" % [response.status(), response.response()])
+ update_progress("Install examples failed! Try it later again.")
+ await get_tree().create_timer(3).timeout
+ stop_progress()
+ return
+ # extract zip to tmp
+ update_progress("Install examples into project")
+ var result := GdUnitFileAccess.extract_zip(zip_file, "res://gdUnit4-examples/")
+ if result.is_error():
+ update_progress("Install examples failed! %s" % result.error_message())
+ await get_tree().create_timer(3).timeout
+ stop_progress()
+ return
+ update_progress("Refresh project")
+ await rescan()
+ await reimport("res://gdUnit4-examples/")
+
+ update_progress("Examples successfully installed")
+ await get_tree().create_timer(3).timeout
+ stop_progress()
+
+
+func rescan() -> void:
+ await get_tree().process_frame
+ var fs := EditorInterface.get_resource_filesystem()
+ fs.scan_sources()
+ while fs.is_scanning():
+ await get_tree().create_timer(1).timeout
+
+
+func reimport(path: String) -> void:
+ await get_tree().process_frame
+ var files := DirAccess.get_files_at(path)
+ EditorInterface.get_resource_filesystem().reimport_files(files)
+ for directory in DirAccess.get_directories_at(path):
+ reimport(directory)
+
+
+func check_for_update() -> void:
+ if not GdUnitSettings.is_update_notification_enabled():
+ return
+ var response :GdUnitUpdateClient.HttpResponse = await _update_client.request_latest_version()
+ if response.status() != 200:
+ printerr("Latest version information cannot be retrieved from GitHub!")
+ printerr("Error: %s" % response.response())
+ return
+ var latest_version := _update_client.extract_latest_version(response)
+ if latest_version.is_greater(GdUnit4Version.current()):
+ var tab_index := _tab_container.get_tab_idx_from_control(_update_tab)
+ _tab_container.set_tab_button_icon(tab_index, GdUnitUiTools.get_icon("Notification", Color.YELLOW))
+ _tab_container.set_tab_tooltip(tab_index, "An new update is available.")
+
+
+func _on_btn_report_bug_pressed() -> void:
+ @warning_ignore("return_value_discarded")
+ OS.shell_open("https://github.com/MikeSchulze/gdUnit4/issues/new?assignees=MikeSchulze&labels=bug&projects=projects%2F5&template=bug_report.yml&title=GD-XXX%3A+Describe+the+issue+briefly")
+
+
+func _on_btn_request_feature_pressed() -> void:
+ @warning_ignore("return_value_discarded")
+ OS.shell_open("https://github.com/MikeSchulze/gdUnit4/issues/new?assignees=MikeSchulze&labels=enhancement&projects=&template=feature_request.md&title=")
+
+
+func _on_btn_install_examples_pressed() -> void:
+ _btn_install.disabled = true
+ await _install_examples()
+ _btn_install.disabled = false
+
+
+func _on_btn_close_pressed() -> void:
+ hide()
+
+
+func _on_btn_property_reset_pressed(property: GdUnitProperty, input: Node, reset_btn: Button) -> void:
+ if input is CheckButton:
+ var is_default_pressed: bool = property.default()
+ (input as CheckButton).button_pressed = is_default_pressed
+ elif input is LineEdit:
+ (input as LineEdit).text = str(property.default())
+ # we have to update manually for text input fields because of no change event is emited
+ _on_property_text_changed(property.default(), property, reset_btn)
+ elif input is OptionButton:
+ (input as OptionButton).select(0)
+ _on_option_selected(0, property, reset_btn)
+ elif input is Button:
+ var value: PackedInt32Array = property.default()
+ (input as Button).text = to_shortcut(value)
+ _on_property_text_changed(value, property, reset_btn)
+
+
+func _on_property_text_changed(new_value: Variant, property: GdUnitProperty, reset_btn: Button) -> void:
+ property.set_value(new_value)
+ reset_btn.disabled = property.value() == property.default()
+ var error: Variant = GdUnitSettings.update_property(property)
+ if error:
+ var label: Label = _property_error.get_child(0) as Label
+ label.set_text(str(error))
+ var control := gui_get_focus_owner()
+ _property_error.show()
+ if control != null:
+ _property_error.position = control.global_position + Vector2(self.position) + Vector2(40, 40)
+
+
+func _on_option_selected(index: int, property: GdUnitProperty, reset_btn: Button) -> void:
+ property.set_value(index)
+ reset_btn.disabled = property.value() == property.default()
+ GdUnitSettings.update_property(property)
+
+
+func _on_shortcut_change(input_button: Button, property: GdUnitProperty, reset_btn: Button) -> void:
+ _input_capture.set_custom_minimum_size(_properties_shortcuts.get_size())
+ _input_capture.visible = true
+ _input_capture.show()
+ _properties_shortcuts.visible = false
+ set_process_input(false)
+ _input_capture.reset()
+ var input_event: InputEventKey = await _input_capture.input_completed
+ input_button.text = input_event.as_text()
+ _on_property_text_changed(to_keys(input_event), property, reset_btn)
+ _properties_shortcuts.visible = true
+ set_process_input(true)
+
+
+func _init_progress(max_value: int) -> void:
+ _progress_bar.visible = true
+ _progress_bar.max_value = max_value
+ _progress_bar.value = 0
+
+
+func _progress() -> void:
+ _progress_bar.value += 1
+
+
+func stop_progress() -> void:
+ _progress_bar.visible = false
+
+
+func update_progress(message: String) -> void:
+ _progress_text.text = message
+ _progress_bar.value += 1
diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd.uid b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd.uid
new file mode 100644
index 0000000..d92d2e4
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd.uid
@@ -0,0 +1 @@
+uid://eqhu8casglok
diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn
new file mode 100644
index 0000000..08afee4
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.tscn
@@ -0,0 +1,349 @@
+[gd_scene load_steps=9 format=3]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/settings/GdUnitSettingsDialog.gd" id="2"]
+[ext_resource type="Texture2D" path="res://addons/gdUnit4/src/ui/settings/logo.png" id="3_isfyl"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/templates/TestSuiteTemplate.tscn" id="4"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn" id="4_nf72w"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn" id="5_n1jtv"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/ui/settings/GdUnitInputCapture.tscn" id="5_xu3j8"]
+[ext_resource type="Script" path="res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd" id="8_2ggr0"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hbbq5"]
+content_margin_left = 10.0
+content_margin_right = 10.0
+bg_color = Color(0.172549, 0.113725, 0.141176, 1)
+border_width_left = 4
+border_width_top = 4
+border_width_right = 4
+border_width_bottom = 4
+border_color = Color(0.87451, 0.0705882, 0.160784, 1)
+border_blend = true
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+shadow_color = Color(0, 0, 0, 0.756863)
+shadow_size = 10
+shadow_offset = Vector2(10, 10)
+
+[node name="GdUnitSettingsDialog" type="Window"]
+disable_3d = true
+gui_embed_subwindows = true
+title = "GdUnit4 Settings"
+initial_position = 1
+size = Vector2i(1400, 600)
+visible = false
+wrap_controls = true
+exclusive = true
+extend_to_title = true
+script = ExtResource("2")
+
+[node name="property_template" type="Control" parent="."]
+visible = false
+layout_mode = 3
+anchors_preset = 0
+offset_left = 4.0
+offset_top = 4.0
+offset_right = 4.0
+offset_bottom = 4.0
+size_flags_horizontal = 0
+
+[node name="Label" type="Label" parent="property_template"]
+layout_mode = 1
+anchors_preset = 4
+anchor_top = 0.5
+anchor_bottom = 0.5
+offset_top = -11.5
+offset_right = 1.0
+offset_bottom = 11.5
+grow_vertical = 2
+
+[node name="btn_reset" type="Button" parent="property_template"]
+layout_mode = 0
+offset_right = 12.0
+offset_bottom = 40.0
+tooltip_text = "Reset to default value"
+clip_text = true
+
+[node name="info" type="Label" parent="property_template"]
+clip_contents = true
+layout_mode = 1
+anchors_preset = 4
+anchor_top = 0.5
+anchor_bottom = 0.5
+offset_top = -11.5
+offset_right = 316.0
+offset_bottom = 11.5
+grow_vertical = 2
+size_flags_horizontal = 3
+max_lines_visible = 1
+
+[node name="sub_category" type="Panel" parent="property_template"]
+layout_mode = 1
+anchors_preset = 10
+anchor_right = 1.0
+offset_right = -220.0
+grow_horizontal = 2
+size_flags_horizontal = 3
+
+[node name="Label" type="Label" parent="property_template/sub_category"]
+layout_mode = 1
+anchors_preset = 4
+anchor_top = 0.5
+anchor_bottom = 0.5
+offset_left = 4.0
+offset_top = -11.5
+offset_right = 5.0
+offset_bottom = 11.5
+grow_vertical = 2
+theme_override_colors/font_color = Color(0.439216, 0.45098, 1, 1)
+
+[node name="GdUnitUpdateClient" type="Node" parent="."]
+script = ExtResource("8_2ggr0")
+
+[node name="Panel" type="Panel" parent="."]
+use_parent_material = true
+clip_contents = true
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="PanelContainer" type="PanelContainer" parent="Panel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="v" type="VBoxContainer" parent="Panel/PanelContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="MarginContainer" type="MarginContainer" parent="Panel/PanelContainer/v"]
+use_parent_material = true
+layout_mode = 2
+size_flags_vertical = 3
+theme_override_constants/margin_left = 4
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 4
+
+[node name="GridContainer" type="HBoxContainer" parent="Panel/PanelContainer/v/MarginContainer"]
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="PanelContainer" type="MarginContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer"]
+use_parent_material = true
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="Panel" type="VBoxContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer"]
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="CenterContainer" type="CenterContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/Panel"]
+use_parent_material = true
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="logo" type="TextureRect" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/Panel/CenterContainer"]
+custom_minimum_size = Vector2(120, 120)
+layout_mode = 2
+size_flags_horizontal = 0
+size_flags_vertical = 4
+texture = ExtResource("3_isfyl")
+expand_mode = 1
+stretch_mode = 5
+
+[node name="CenterContainer2" type="MarginContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/Panel"]
+use_parent_material = true
+custom_minimum_size = Vector2(0, 30)
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="version" type="RichTextLabel" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/Panel/CenterContainer2"]
+unique_name_in_owner = true
+auto_translate_mode = 2
+use_parent_material = true
+clip_contents = false
+layout_mode = 2
+size_flags_horizontal = 3
+localize_numeral_system = false
+bbcode_enabled = true
+scroll_active = false
+meta_underlined = false
+
+[node name="VBoxContainer" type="VBoxContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer"]
+custom_minimum_size = Vector2(200, 0)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+alignment = 2
+
+[node name="btn_report_bug" type="Button" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/VBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+tooltip_text = "Press to create a bug report"
+text = "Report Bug"
+
+[node name="btn_request_feature" type="Button" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/VBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+tooltip_text = "Press to create a feature request"
+text = "Request Feature"
+
+[node name="btn_install_examples" type="Button" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/VBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+tooltip_text = "Press to install the advanced test examples"
+disabled = true
+text = "Install Examples"
+
+[node name="Properties" type="TabContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+current_tab = 0
+
+[node name="Common" type="ScrollContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties"]
+layout_mode = 2
+metadata/_tab_index = 0
+
+[node name="common-content" type="VBoxContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties/Common"]
+unique_name_in_owner = true
+clip_contents = true
+custom_minimum_size = Vector2(1026, 0)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="Hooks" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties" instance=ExtResource("4_nf72w")]
+visible = false
+layout_mode = 2
+
+[node name="UI" type="ScrollContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties"]
+visible = false
+layout_mode = 2
+metadata/_tab_index = 2
+
+[node name="ui-content" type="VBoxContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties/UI"]
+unique_name_in_owner = true
+clip_contents = true
+custom_minimum_size = Vector2(741, 0)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="Shortcuts" type="ScrollContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties"]
+visible = false
+layout_mode = 2
+metadata/_tab_index = 3
+
+[node name="shortcut-content" type="VBoxContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties/Shortcuts"]
+unique_name_in_owner = true
+clip_contents = true
+custom_minimum_size = Vector2(683, 0)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="Report" type="ScrollContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties"]
+visible = false
+layout_mode = 2
+metadata/_tab_index = 4
+
+[node name="report-content" type="VBoxContainer" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties/Report"]
+unique_name_in_owner = true
+clip_contents = true
+custom_minimum_size = Vector2(667, 0)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="Templates" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties" instance=ExtResource("4")]
+visible = false
+layout_mode = 2
+metadata/_tab_index = 5
+
+[node name="Update" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties" instance=ExtResource("5_n1jtv")]
+unique_name_in_owner = true
+visible = false
+layout_mode = 2
+metadata/_tab_index = 6
+
+[node name="GdUnitInputCapture" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties" instance=ExtResource("5_xu3j8")]
+unique_name_in_owner = true
+visible = false
+modulate = Color(1.54884e-09, 1.54884e-09, 1.54884e-09, 0.1)
+z_index = 1
+z_as_relative = false
+layout_mode = 2
+size_flags_horizontal = 1
+size_flags_vertical = 1
+
+[node name="propertyError" type="PopupPanel" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties"]
+unique_name_in_owner = true
+initial_position = 1
+size = Vector2i(400, 100)
+theme_override_styles/panel = SubResource("StyleBoxFlat_hbbq5")
+
+[node name="Label" type="Label" parent="Panel/PanelContainer/v/MarginContainer/GridContainer/Properties/propertyError"]
+offset_left = 10.0
+offset_top = 4.0
+offset_right = 390.0
+offset_bottom = 96.0
+theme_override_colors/font_color = Color(0.858824, 0, 0.109804, 1)
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="MarginContainer2" type="MarginContainer" parent="Panel/PanelContainer/v"]
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_constants/margin_left = 4
+theme_override_constants/margin_right = 4
+theme_override_constants/margin_bottom = 4
+
+[node name="HBoxContainer" type="HBoxContainer" parent="Panel/PanelContainer/v/MarginContainer2"]
+layout_mode = 2
+size_flags_horizontal = 3
+alignment = 2
+
+[node name="ProgressBar" type="ProgressBar" parent="Panel/PanelContainer/v/MarginContainer2/HBoxContainer"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="progress_lbl" type="Label" parent="Panel/PanelContainer/v/MarginContainer2/HBoxContainer/ProgressBar"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+clip_text = true
+
+[node name="btn_close" type="Button" parent="Panel/PanelContainer/v/MarginContainer2/HBoxContainer"]
+custom_minimum_size = Vector2(200, 0)
+layout_mode = 2
+text = "Close"
+
+[connection signal="close_requested" from="." to="." method="_on_btn_close_pressed"]
+[connection signal="pressed" from="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/VBoxContainer/btn_report_bug" to="." method="_on_btn_report_bug_pressed"]
+[connection signal="pressed" from="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/VBoxContainer/btn_request_feature" to="." method="_on_btn_request_feature_pressed"]
+[connection signal="pressed" from="Panel/PanelContainer/v/MarginContainer/GridContainer/PanelContainer/VBoxContainer/btn_install_examples" to="." method="_on_btn_install_examples_pressed"]
+[connection signal="pressed" from="Panel/PanelContainer/v/MarginContainer2/HBoxContainer/btn_close" to="." method="_on_btn_close_pressed"]
diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd
new file mode 100644
index 0000000..3b01f34
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd
@@ -0,0 +1,255 @@
+@tool
+extends ScrollContainer
+
+
+@onready var _hooks_tree: Tree = %hooks_tree
+@onready var _hook_description: RichTextLabel = %hook_description
+@onready var _btn_move_up: Button = %hook_actions/btn_move_up
+@onready var _btn_move_down: Button = %hook_actions/btn_move_down
+@onready var _btn_delete: Button = %hook_actions/btn_delete_hook
+@onready var _select_hook_dlg: FileDialog = %select_hook_dlg
+@onready var _error_msg_popup :AcceptDialog = %error_msg_popup
+
+var _selected_hook_item: TreeItem = null
+var _root: TreeItem
+var _system_box_style: StyleBoxFlat
+var _priority_box_style: StyleBoxFlat
+
+func _ready() -> void:
+ _setup_styles()
+ _setup_buttons()
+ _setup_tree()
+ _load_registered_hooks()
+
+
+func _setup_styles() -> void:
+ _system_box_style = StyleBoxFlat.new()
+ _system_box_style.bg_color = Color(1.0, 0.76, 0.03, 1)
+ _system_box_style.corner_radius_top_left = 6
+ _system_box_style.corner_radius_top_right = 6
+ _system_box_style.corner_radius_bottom_left = 6
+ _system_box_style.corner_radius_bottom_right = 6
+ _priority_box_style = _system_box_style.duplicate()
+ _priority_box_style.bg_color = Color(0.26, 0.54, 0.89, 1)
+
+
+func _setup_buttons() -> void:
+ #if Engine.is_editor_hint():
+ # _btn_move_up.icon = GdUnitUiTools.get_icon("MoveUp")
+ # _btn_move_down.icon = GdUnitUiTools.get_icon("MoveDown")
+ # _btn_add.icon = GdUnitUiTools.get_icon("Add")
+ # _btn_delete.icon = GdUnitUiTools.get_icon("Remove")
+ pass
+
+
+func _setup_tree() -> void:
+ _hooks_tree.clear()
+ _root = _hooks_tree.create_item()
+ _hooks_tree.set_columns(2)
+ _hooks_tree.set_column_custom_minimum_width(1, 32)
+ _hooks_tree.set_column_expand(1, false)
+ _hooks_tree.set_hide_root(true)
+ _hooks_tree.set_hide_folding(true)
+ _hooks_tree.set_select_mode(Tree.SELECT_SINGLE)
+ _hooks_tree.item_selected.connect(_on_hook_selected)
+ _hooks_tree.item_edited.connect(_on_item_edited)
+
+
+func _load_registered_hooks() -> void:
+ var hook_service := GdUnitTestSessionHookService.instance()
+ for hook: GdUnitTestSessionHook in hook_service.enigne_hooks:
+ _create_hook_tree_item(hook)
+
+ # Select first item if any
+ if _root.get_child_count() > 0:
+ var first_item: TreeItem = _root.get_first_child()
+ first_item.select(0)
+ _on_hook_selected()
+
+
+func _create_hook_tree_item(hook: GdUnitTestSessionHook) -> TreeItem:
+ var item: TreeItem = _hooks_tree.create_item(_root)
+ item.set_custom_minimum_height(26)
+ # Column 0: Hook info with custom drawing
+ item.set_cell_mode(0, TreeItem.CELL_MODE_CUSTOM)
+ item.set_custom_draw_callback(0, _draw_hook_item)
+ item.set_editable(0, false)
+ item.set_metadata(0, hook)
+ # Column 1: Checkbox for enable/disable
+ item.set_cell_mode(1, TreeItem.CELL_MODE_CHECK)
+ item.set_checked(1, GdUnitTestSessionHookService.is_enabled(hook))
+ item.set_editable(1, true)
+ item.set_custom_bg_color(1, _hook_bg_color(hook))
+ item.set_tooltip_text(1, "Enable/Disable the Hook")
+ item.propagate_check(1)
+
+ if _is_system_hook(hook):
+ item.set_tooltip_text(0, "System hook - (Read-only)")
+ else:
+ item.set_tooltip_text(0, "User hook")
+ return item
+
+
+func _hook_bg_color(hook: GdUnitTestSessionHook) -> Color:
+ if _is_system_hook(hook):
+ return Color(0.133, 0.118, 0.090, 1) # Brownish background for system hooks
+ return Color(0.176, 0.196, 0.235, 1) # Dark background #2d3142
+
+
+func _draw_hook_item(item: TreeItem, rect: Rect2) -> void:
+ var hook := _get_hook(item)
+ var is_system := _is_system_hook(hook)
+ var is_selected := item == _selected_hook_item
+
+ # Draw background
+ var bg_color := _hook_bg_color(hook) # Dark background #2d3142
+ if is_selected:
+ bg_color = bg_color.lerp(Color(0.2, 0.4, 0.6, 0.3), 0.5) # Blue tint for selection
+ _hooks_tree.draw_rect(rect, bg_color)
+
+ # Draw left border for system hooks
+ if is_system:
+ var border_rect := Rect2(rect.position.x, rect.position.y, 3, rect.size.y)
+ _hooks_tree.draw_rect(border_rect, Color(1.0, 0.76, 0.03, 1)) # Yellow border
+
+ var font := _hooks_tree.get_theme_default_font()
+
+ # Draw hook name
+ var hook_name := hook.name
+ var text_pos := Vector2(rect.position.x + ( 15 if is_system else 12), rect.position.y + 18)
+ var text_color := Color(0.95, 0.95, 0.95, 1)
+ _hooks_tree.draw_string(font, text_pos, hook_name, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, text_color)
+
+ # Draw system badge if needed
+ if is_system:
+ var badge_x := rect.position.x + rect.end.x - 100
+ var badge_y := rect.position.y + 14
+ var system_badge_rect := Rect2(badge_x, badge_y-8, 48, 16)
+ _hooks_tree.draw_style_box(_system_box_style, system_badge_rect)
+
+ var system_text_pos := Vector2(badge_x + 4, badge_y + 4)
+ var system_font_size := 10
+ _hooks_tree.draw_string(font, system_text_pos, "SYSTEM", HORIZONTAL_ALIGNMENT_CENTER, -1, system_font_size, Color(0.1, 0.1, 0.1, 1))
+
+
+func _create_hook_display_text(hook_name: String, priority: int, is_system: bool) -> String:
+ var text := hook_name + "\n"
+ text += "Priority: [color=#4299e1][bgcolor=#4299e1] " + str(priority) + " [/bgcolor][/color]"
+
+ if is_system:
+ text += " [color=#1a202c][bgcolor=#ffc107] SYSTEM [/bgcolor][/color]"
+
+ return text
+
+
+func _update_hook_description() -> void:
+ if _selected_hook_item == null:
+ _hook_description.text = "[i]Select a hook to view its description[/i]"
+ return
+ _hook_description.text = _get_hook(_selected_hook_item).description
+
+
+func _update_hook_buttons() -> void:
+ # Is nothing selected disable the move and delete buttons
+ if _selected_hook_item == null:
+ _btn_move_up.disabled = true
+ _btn_move_down.disabled = true
+ _btn_delete.disabled = true
+ return
+
+ var hook := _get_hook(_selected_hook_item)
+ var is_system := _is_system_hook(hook)
+
+ # Disable the move and delete buttons for system hooks by default
+ if is_system:
+ _btn_move_up.disabled = true
+ _btn_move_down.disabled = true
+ _btn_delete.disabled = true
+ return
+
+ var prev_item: TreeItem = _selected_hook_item.get_prev()
+ var next_item: TreeItem = _selected_hook_item.get_next()
+
+ if prev_item != null:
+ var prev_hook := _get_hook(prev_item)
+ _btn_move_up.disabled = _is_system_hook(prev_hook)
+
+ _btn_move_down.disabled = next_item == null
+ _btn_delete.disabled = false
+
+
+static func _get_hook(item: TreeItem) -> GdUnitTestSessionHook:
+ return item.get_metadata(0)
+
+
+static func _is_system_hook(hook: GdUnitTestSessionHook) -> bool:
+ if hook == null:
+ return false
+ return hook.get_meta("SYSTEM_HOOK")
+
+
+func _on_hook_selected() -> void:
+ _selected_hook_item = _hooks_tree.get_selected()
+ _update_hook_buttons()
+ _update_hook_description()
+
+
+func _on_item_edited() -> void:
+ var selected_hook_item := _hooks_tree.get_selected()
+ if selected_hook_item != null:
+ var hook := _get_hook(selected_hook_item)
+ var is_enabled := selected_hook_item.is_checked(1)
+ GdUnitTestSessionHookService.instance().enable_hook(hook, is_enabled)
+
+
+func _on_btn_add_hook_pressed() -> void:
+ _select_hook_dlg.show()
+
+
+func _on_select_hook_dlg_file_selected(path: String) -> void:
+ _select_hook_dlg.set_current_path(path)
+ _on_select_hook_dlg_confirmed()
+
+
+func _on_select_hook_dlg_confirmed() -> void:
+ _select_hook_dlg.hide()
+ var result := GdUnitTestSessionHookService.instance().load_hook(_select_hook_dlg.get_current_path())
+ if result.is_error():
+ _error_msg_popup.dialog_text = result.error_message()
+ _error_msg_popup.show()
+ return
+
+ var hook: GdUnitTestSessionHook = result.value()
+ result = GdUnitTestSessionHookService.instance().register(hook)
+ if result.is_error():
+ _error_msg_popup.dialog_text = result.error_message()
+ _error_msg_popup.show()
+ return
+
+ var hook_added := _create_hook_tree_item(hook)
+ _hooks_tree.set_selected(hook_added, 0)
+
+
+func _on_btn_delete_hook_pressed() -> void:
+ if _selected_hook_item != null:
+ _root.remove_child(_selected_hook_item)
+ GdUnitTestSessionHookService.instance()\
+ .unregister(_get_hook(_selected_hook_item))
+ _selected_hook_item = null
+ _update_hook_buttons()
+
+
+func _on_btn_move_up_pressed() -> void:
+ var prev := _selected_hook_item.get_prev()
+ _selected_hook_item.move_before(prev)
+ GdUnitTestSessionHookService.instance()\
+ .move_before(_get_hook(_selected_hook_item), _get_hook(prev))
+ _update_hook_buttons()
+
+
+func _on_btn_move_down_pressed() -> void:
+ var next := _selected_hook_item.get_next()
+ _selected_hook_item.move_after(next)
+ GdUnitTestSessionHookService.instance()\
+ .move_after(_get_hook(_selected_hook_item), _get_hook(next))
+ _update_hook_buttons()
diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd.uid b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd.uid
new file mode 100644
index 0000000..cbb5b86
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd.uid
@@ -0,0 +1 @@
+uid://cmclisg3m8k2n
diff --git a/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn
new file mode 100644
index 0000000..8eec309
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.tscn
@@ -0,0 +1,148 @@
+[gd_scene format=3 uid="uid://41l7a46fol5m"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/settings/GdUnitSettingsTabHooks.gd" id="1_8yffn"]
+
+[sub_resource type="Image" id="Image_h5sr5"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 234, 234, 234, 12, 224, 224, 224, 255, 224, 224, 224, 255, 234, 234, 234, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 1, 225, 225, 225, 174, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 173, 255, 255, 255, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 123, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 122, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 74, 224, 224, 224, 253, 224, 224, 224, 253, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 253, 224, 224, 224, 253, 224, 224, 224, 74, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 228, 228, 228, 37, 224, 224, 224, 240, 224, 224, 224, 255, 224, 224, 224, 122, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 123, 224, 224, 224, 255, 224, 224, 224, 239, 228, 228, 228, 37, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 200, 224, 224, 224, 255, 224, 224, 224, 172, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 1, 224, 224, 224, 173, 224, 224, 224, 255, 225, 225, 225, 199, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 180, 224, 224, 224, 193, 234, 234, 234, 12, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 234, 234, 234, 12, 224, 224, 224, 193, 224, 224, 224, 179, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 180, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 181, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 180, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_77fm0"]
+image = SubResource("Image_h5sr5")
+
+[sub_resource type="Image" id="Image_77fm0"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 181, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 180, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 181, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 181, 224, 224, 224, 193, 234, 234, 234, 12, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 234, 234, 234, 12, 224, 224, 224, 193, 224, 224, 224, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 200, 224, 224, 224, 255, 224, 224, 224, 173, 255, 255, 255, 1, 224, 224, 224, 255, 224, 224, 224, 255, 255, 255, 255, 1, 225, 225, 225, 174, 224, 224, 224, 255, 225, 225, 225, 199, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 228, 228, 228, 37, 224, 224, 224, 239, 224, 224, 224, 255, 224, 224, 224, 122, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 123, 224, 224, 224, 255, 224, 224, 224, 239, 227, 227, 227, 36, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 74, 224, 224, 224, 253, 224, 224, 224, 253, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 253, 224, 224, 224, 253, 224, 224, 224, 73, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 123, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 122, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 1, 224, 224, 224, 173, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 172, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 234, 234, 234, 12, 224, 224, 224, 255, 224, 224, 224, 255, 234, 234, 234, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_rewru"]
+image = SubResource("Image_77fm0")
+
+[sub_resource type="Image" id="Image_kppp6"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_manhx"]
+image = SubResource("Image_kppp6")
+
+[sub_resource type="Image" id="Image_rewru"]
+data = {
+"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 227, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 225, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 73, 224, 224, 224, 226, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 225, 226, 226, 226, 70, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+"format": "RGBA8",
+"height": 16,
+"mipmaps": false,
+"width": 16
+}
+
+[sub_resource type="ImageTexture" id="ImageTexture_4h4u1"]
+image = SubResource("Image_rewru")
+
+[node name="Hooks" type="ScrollContainer"]
+custom_minimum_size = Vector2(400, 300)
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+script = ExtResource("1_8yffn")
+metadata/_tab_index = 1
+
+[node name="HBoxContainer" type="HBoxContainer" parent="."]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="hooks_content" type="VBoxContainer" parent="HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="hooks_tree" type="Tree" parent="HBoxContainer/hooks_content"]
+unique_name_in_owner = true
+layout_direction = 2
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+hide_folding = true
+hide_root = true
+
+[node name="hook_description" type="RichTextLabel" parent="HBoxContainer/hooks_content"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 120)
+layout_mode = 2
+size_flags_vertical = 2
+bbcode_enabled = true
+text = "The test result Html reporting hook."
+scroll_active = false
+
+[node name="hook_actions" type="VBoxContainer" parent="HBoxContainer"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(80, 0)
+layout_mode = 2
+size_flags_horizontal = 0
+theme_override_constants/separation = 5
+
+[node name="btn_move_up" type="Button" parent="HBoxContainer/hook_actions"]
+layout_mode = 2
+tooltip_text = "Move hook up in priority"
+disabled = true
+icon = SubResource("ImageTexture_77fm0")
+icon_alignment = 1
+
+[node name="btn_move_down" type="Button" parent="HBoxContainer/hook_actions"]
+layout_mode = 2
+tooltip_text = "Move hook down in priority"
+disabled = true
+icon = SubResource("ImageTexture_rewru")
+icon_alignment = 1
+
+[node name="btn_add_hook" type="Button" parent="HBoxContainer/hook_actions"]
+layout_mode = 2
+tooltip_text = "Add new hook"
+icon = SubResource("ImageTexture_manhx")
+icon_alignment = 1
+
+[node name="btn_delete_hook" type="Button" parent="HBoxContainer/hook_actions"]
+layout_mode = 2
+tooltip_text = "Delete selected hook"
+disabled = true
+icon = SubResource("ImageTexture_4h4u1")
+icon_alignment = 1
+
+[node name="select_hook_dlg" type="FileDialog" parent="."]
+unique_name_in_owner = true
+disable_3d = true
+title = "Open a File"
+initial_position = 3
+current_screen = 0
+ok_button_text = "Open"
+file_mode = 0
+filters = PackedStringArray("*.gd")
+
+[node name="error_msg_popup" type="AcceptDialog" parent="."]
+unique_name_in_owner = true
+initial_position = 3
+current_screen = 0
+
+[connection signal="pressed" from="HBoxContainer/hook_actions/btn_move_up" to="." method="_on_btn_move_up_pressed"]
+[connection signal="pressed" from="HBoxContainer/hook_actions/btn_move_down" to="." method="_on_btn_move_down_pressed"]
+[connection signal="pressed" from="HBoxContainer/hook_actions/btn_add_hook" to="." method="_on_btn_add_hook_pressed"]
+[connection signal="pressed" from="HBoxContainer/hook_actions/btn_delete_hook" to="." method="_on_btn_delete_hook_pressed"]
+[connection signal="confirmed" from="select_hook_dlg" to="." method="_on_select_hook_dlg_confirmed"]
+[connection signal="file_selected" from="select_hook_dlg" to="." method="_on_select_hook_dlg_file_selected"]
diff --git a/addons/gdUnit4/src/ui/settings/logo.png b/addons/gdUnit4/src/ui/settings/logo.png
new file mode 100644
index 0000000..12de79f
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/logo.png
Binary files differ
diff --git a/addons/gdUnit4/src/ui/settings/logo.png.import b/addons/gdUnit4/src/ui/settings/logo.png.import
new file mode 100644
index 0000000..0141c5e
--- /dev/null
+++ b/addons/gdUnit4/src/ui/settings/logo.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bo6npdrbk02dk"
+path="res://.godot/imported/logo.png-deda0e4ba02a0b9e4e4a830029a5817f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/ui/settings/logo.png"
+dest_files=["res://.godot/imported/logo.png-deda0e4ba02a0b9e4e4a830029a5817f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd
new file mode 100644
index 0000000..be5c352
--- /dev/null
+++ b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd
@@ -0,0 +1,129 @@
+@tool
+extends MarginContainer
+
+@onready var _template_editor :CodeEdit = $VBoxContainer/EdiorLayout/Editor
+@onready var _tags_editor :CodeEdit = $Tags/MarginContainer/TextEdit
+@onready var _title_bar :Panel = $VBoxContainer/sub_category
+@onready var _save_button :Button = $VBoxContainer/Panel/HBoxContainer/Save
+@onready var _selected_type :OptionButton = $VBoxContainer/EdiorLayout/Editor/MarginContainer/HBoxContainer/SelectType
+@onready var _show_tags :PopupPanel = $Tags
+
+
+var gd_key_words :PackedStringArray = ["extends", "class_name", "const", "var", "onready", "func", "void", "pass"]
+var gdunit_key_words :PackedStringArray = ["GdUnitTestSuite", "before", "after", "before_test", "after_test"]
+var _selected_template :int
+
+
+func _ready() -> void:
+ setup_editor_colors()
+ setup_fonts()
+ setup_supported_types()
+ load_template(GdUnitTestSuiteTemplate.TEMPLATE_ID_GD)
+ setup_tags_help()
+
+
+func _notification(what :int) -> void:
+ if what == EditorSettings.NOTIFICATION_EDITOR_SETTINGS_CHANGED:
+ setup_fonts()
+
+
+func setup_editor_colors() -> void:
+ if not Engine.is_editor_hint():
+ return
+
+ var background_color := get_editor_color("text_editor/theme/highlighting/background_color", Color(0.1155, 0.132, 0.1595, 1))
+ var text_color := get_editor_color("text_editor/theme/highlighting/text_color", Color(0.8025, 0.81, 0.8225, 1))
+ var selection_color := get_editor_color("text_editor/theme/highlighting/selection_color", Color(0.44, 0.73, 0.98, 0.4))
+
+ for e :CodeEdit in [_template_editor, _tags_editor]:
+ var editor :CodeEdit = e
+ editor.add_theme_color_override("background_color", background_color)
+ editor.add_theme_color_override("font_color", text_color)
+ editor.add_theme_color_override("font_readonly_color", text_color)
+ editor.add_theme_color_override("font_selected_color", selection_color)
+ setup_highlighter(editor)
+
+
+func setup_highlighter(editor :CodeEdit) -> void:
+ var highlighter := CodeHighlighter.new()
+ editor.set_syntax_highlighter(highlighter)
+ var number_color := get_editor_color("text_editor/theme/highlighting/number_color", Color(0.63, 1, 0.88, 1))
+ var symbol_color := get_editor_color("text_editor/theme/highlighting/symbol_color", Color(0.67, 0.79, 1, 1))
+ var function_color := get_editor_color("text_editor/theme/highlighting/function_color", Color(0.34, 0.7, 1, 1))
+ var member_variable_color := get_editor_color("text_editor/theme/highlighting/member_variable_color", Color(0.736, 0.88, 1, 1))
+ var comment_color := get_editor_color("text_editor/theme/highlighting/comment_color", Color(0.8025, 0.81, 0.8225, 0.5))
+ var keyword_color := get_editor_color("text_editor/theme/highlighting/keyword_color", Color(1, 0.44, 0.52, 1))
+ var base_type_color := get_editor_color("text_editor/theme/highlighting/base_type_color", Color(0.26, 1, 0.76, 1))
+ var annotation_color := get_editor_color("text_editor/theme/highlighting/gdscript/annotation_color", Color(1, 0.7, 0.45, 1))
+
+ highlighter.clear_color_regions()
+ highlighter.clear_keyword_colors()
+ highlighter.add_color_region("#", "", comment_color, true)
+ highlighter.add_color_region("${", "}", Color.YELLOW)
+ highlighter.add_color_region("'", "'", Color.YELLOW)
+ highlighter.add_color_region("\"", "\"", Color.YELLOW)
+ highlighter.number_color = number_color
+ highlighter.symbol_color = symbol_color
+ highlighter.function_color = function_color
+ highlighter.member_variable_color = member_variable_color
+ highlighter.add_keyword_color("@", annotation_color)
+ highlighter.add_keyword_color("warning_ignore", annotation_color)
+ for word in gd_key_words:
+ highlighter.add_keyword_color(word, keyword_color)
+ for word in gdunit_key_words:
+ highlighter.add_keyword_color(word, base_type_color)
+
+
+## Using this function to avoid null references to colors on inital Godot installations.
+## For more details show https://github.com/MikeSchulze/gdUnit4/issues/533
+func get_editor_color(property_name: String, default: Color) -> Color:
+ var settings := EditorInterface.get_editor_settings()
+ return settings.get_setting(property_name) if settings.has_setting(property_name) else default
+
+
+func setup_fonts() -> void:
+ if _template_editor:
+ @warning_ignore("return_value_discarded")
+ GdUnitFonts.init_fonts(_template_editor)
+ var font_size := GdUnitFonts.init_fonts(_tags_editor)
+ _title_bar.size.y = font_size + 16
+ _title_bar.custom_minimum_size.y = font_size + 16
+
+
+func setup_supported_types() -> void:
+ _selected_type.clear()
+ _selected_type.add_item("GD - GDScript", GdUnitTestSuiteTemplate.TEMPLATE_ID_GD)
+ _selected_type.add_item("C# - CSharpScript", GdUnitTestSuiteTemplate.TEMPLATE_ID_CS)
+
+
+func setup_tags_help() -> void:
+ _tags_editor.set_text(GdUnitTestSuiteTemplate.load_tags(_selected_template))
+
+
+func load_template(template_id :int) -> void:
+ _selected_template = template_id
+ _template_editor.set_text(GdUnitTestSuiteTemplate.load_template(template_id))
+
+
+func _on_Restore_pressed() -> void:
+ _template_editor.set_text(GdUnitTestSuiteTemplate.default_template(_selected_template))
+ GdUnitTestSuiteTemplate.reset_to_default(_selected_template)
+ _save_button.disabled = true
+
+
+func _on_Save_pressed() -> void:
+ GdUnitTestSuiteTemplate.save_template(_selected_template, _template_editor.get_text())
+ _save_button.disabled = true
+
+
+func _on_Tags_pressed() -> void:
+ _show_tags.popup_centered_ratio(.5)
+
+
+func _on_Editor_text_changed() -> void:
+ _save_button.disabled = false
+
+
+func _on_SelectType_item_selected(index :int) -> void:
+ load_template(_selected_type.get_item_id(index))
+ setup_tags_help()
diff --git a/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd.uid b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd.uid
new file mode 100644
index 0000000..2c03cb1
--- /dev/null
+++ b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd.uid
@@ -0,0 +1 @@
+uid://dh7k6amf26gro
diff --git a/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.tscn b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.tscn
new file mode 100644
index 0000000..5ccc443
--- /dev/null
+++ b/addons/gdUnit4/src/ui/templates/TestSuiteTemplate.tscn
@@ -0,0 +1,127 @@
+[gd_scene load_steps=2 format=3 uid="uid://dte0m2endcgtu"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/ui/templates/TestSuiteTemplate.gd" id="1"]
+
+[node name="TestSuiteTemplate" type="MarginContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+script = ExtResource("1")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="sub_category" type="Panel" parent="VBoxContainer"]
+custom_minimum_size = Vector2(0, 30)
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="Label" type="Label" parent="VBoxContainer/sub_category"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 4.0
+offset_right = 4.0
+offset_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+text = "Test Suite Template
+"
+
+[node name="EdiorLayout" type="VBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="Editor" type="CodeEdit" parent="VBoxContainer/EdiorLayout"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="MarginContainer" type="MarginContainer" parent="VBoxContainer/EdiorLayout/Editor"]
+layout_mode = 1
+anchors_preset = 12
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = -31.0
+grow_horizontal = 2
+grow_vertical = 0
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/EdiorLayout/Editor/MarginContainer"]
+layout_mode = 2
+size_flags_vertical = 8
+alignment = 2
+
+[node name="Tags" type="Button" parent="VBoxContainer/EdiorLayout/Editor/MarginContainer/HBoxContainer"]
+layout_mode = 2
+tooltip_text = "Shows supported tags."
+text = "Supported Tags"
+
+[node name="SelectType" type="OptionButton" parent="VBoxContainer/EdiorLayout/Editor/MarginContainer/HBoxContainer"]
+layout_mode = 2
+tooltip_text = "Select the script type specific template."
+item_count = 2
+selected = 0
+popup/item_0/text = "GD - GDScript"
+popup/item_0/id = 1000
+popup/item_1/text = "C# - CSharpScript"
+popup/item_1/id = 2000
+
+[node name="Panel" type="MarginContainer" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/Panel"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+alignment = 2
+
+[node name="Restore" type="Button" parent="VBoxContainer/Panel/HBoxContainer"]
+layout_mode = 2
+text = "Restore"
+
+[node name="Save" type="Button" parent="VBoxContainer/Panel/HBoxContainer"]
+layout_mode = 2
+disabled = true
+text = "Save"
+
+[node name="Tags" type="PopupPanel" parent="."]
+size = Vector2i(300, 100)
+unresizable = false
+content_scale_aspect = 4
+
+[node name="MarginContainer" type="MarginContainer" parent="Tags"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 4.0
+offset_top = 4.0
+offset_right = -856.0
+offset_bottom = -552.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="TextEdit" type="CodeEdit" parent="Tags/MarginContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+editable = false
+context_menu_enabled = false
+shortcut_keys_enabled = false
+virtual_keyboard_enabled = false
+
+[connection signal="text_changed" from="VBoxContainer/EdiorLayout/Editor" to="." method="_on_Editor_text_changed"]
+[connection signal="pressed" from="VBoxContainer/EdiorLayout/Editor/MarginContainer/HBoxContainer/Tags" to="." method="_on_Tags_pressed"]
+[connection signal="item_selected" from="VBoxContainer/EdiorLayout/Editor/MarginContainer/HBoxContainer/SelectType" to="." method="_on_SelectType_item_selected"]
+[connection signal="pressed" from="VBoxContainer/Panel/HBoxContainer/Restore" to="." method="_on_Restore_pressed"]
+[connection signal="pressed" from="VBoxContainer/Panel/HBoxContainer/Save" to="." method="_on_Save_pressed"]
diff --git a/addons/gdUnit4/src/update/GdMarkDownReader.gd b/addons/gdUnit4/src/update/GdMarkDownReader.gd
new file mode 100644
index 0000000..dab19e4
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdMarkDownReader.gd
@@ -0,0 +1,405 @@
+@tool
+extends RefCounted
+
+const GdUnitUpdateClient = preload("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd")
+
+const FONT_H1 := 22
+const FONT_H2 := 20
+const FONT_H3 := 18
+const FONT_H4 := 16
+const FONT_H5 := 14
+const FONT_H6 := 12
+
+const HORIZONTAL_RULE := "[img=4000x2]res://addons/gdUnit4/src/update/assets/horizontal-line2.png[/img]"
+const HEADER_RULE := "[font_size=%d]$1[/font_size]"
+const HEADER_CENTERED_RULE := "[font_size=%d][center]$1[/center][/font_size]"
+
+const image_download_folder := "res://addons/gdUnit4/tmp-update/"
+
+const exclude_font_size := "\b(?!(?:(font_size))\b)"
+
+var md_replace_patterns := [
+ # comments
+ [regex("(?m)^\\n?\\s*<!--[\\s\\S]*?-->\\s*\\n?"), ""],
+
+ # horizontal rules
+ [regex("(?m)^[ ]{0,3}---$"), HORIZONTAL_RULE],
+ [regex("(?m)^[ ]{0,3}___$"), HORIZONTAL_RULE],
+ [regex("(?m)^[ ]{0,3}\\*\\*\\*$"), HORIZONTAL_RULE],
+
+ # headers
+ [regex("(?m)^###### (.*)"), HEADER_RULE % FONT_H6],
+ [regex("(?m)^##### (.*)"), HEADER_RULE % FONT_H5],
+ [regex("(?m)^#### (.*)"), HEADER_RULE % FONT_H4],
+ [regex("(?m)^### (.*)"), HEADER_RULE % FONT_H3],
+ [regex("(?m)^## (.*)"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H2],
+ [regex("(?m)^# (.*)"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H1],
+ [regex("(?m)^(.+)=={2,}$"), HEADER_RULE % FONT_H1],
+ [regex("(?m)^(.+)--{2,}$"), HEADER_RULE % FONT_H2],
+ # html headers
+ [regex("<h1>((.*?\\R?)+)<\\/h1>"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H1],
+ [regex("<h1[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h1>"), (HEADER_CENTERED_RULE + HORIZONTAL_RULE) % FONT_H1],
+ [regex("<h2>((.*?\\R?)+)<\\/h2>"), (HEADER_RULE + HORIZONTAL_RULE) % FONT_H2],
+ [regex("<h2[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h2>"), (HEADER_CENTERED_RULE + HORIZONTAL_RULE) % FONT_H1],
+ [regex("<h3>((.*?\\R?)+)<\\/h3>"), HEADER_RULE % FONT_H3],
+ [regex("<h3[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h3>"), HEADER_CENTERED_RULE % FONT_H3],
+ [regex("<h4>((.*?\\R?)+)<\\/h4>"), HEADER_RULE % FONT_H4],
+ [regex("<h4[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h4>"), HEADER_CENTERED_RULE % FONT_H4],
+ [regex("<h5>((.*?\\R?)+)<\\/h5>"), HEADER_RULE % FONT_H5],
+ [regex("<h5[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h5>"), HEADER_CENTERED_RULE % FONT_H5],
+ [regex("<h6>((.*?\\R?)+)<\\/h6>"), HEADER_RULE % FONT_H6],
+ [regex("<h6[ ]*align[ ]*=[ ]*\"center\">((.*?\\R?)+)<\\/h6>"), HEADER_CENTERED_RULE % FONT_H6],
+
+ # asterics
+ #[regex("(\\*)"), "xxx$1xxx"],
+
+ # extract/compile image references
+ [regex("!\\[(.*?)\\]\\[(.*?)\\]"), process_image_references],
+ # extract images with path and optional tool tip
+ [regex("!\\[(.*?)\\]\\((.*?)(( )+(.*?))?\\)"), process_image],
+
+ # links
+ [regex("([!]|)\\[(.+)\\]\\(([^ ]+?)\\)"), "[url={\"url\":\"$3\"}]$2[/url]"],
+ # links with tool tip
+ [regex("([!]|)\\[(.+)\\]\\(([^ ]+?)( \"(.+)\")?\\)"), "[url={\"url\":\"$3\", \"tool_tip\":\"$5\"}]$2[/url]"],
+ # links to github, as shorted link
+ [regex("(https://github.*/?/(\\S+))"), '[url={"url":"$1", "tool_tip":"$1"}]#$2[/url]'],
+
+ # embeded text
+ [regex("(?m)^[ ]{0,3}>(.*?)$"), "[img=50x14]res://addons/gdUnit4/src/update/assets/embedded.png[/img][i]$1[/i]"],
+
+ # italic + bold font
+ [regex("[_]{3}(.*?)[_]{3}"), "[i][b]$1[/b][/i]"],
+ [regex("[\\*]{3}(.*?)[\\*]{3}"), "[i][b]$1[/b][/i]"],
+ # bold font
+ [regex("<b>(.*?)<\\/b>"), "[b]$1[/b]"],
+ [regex("[_]{2}(.*?)[_]{2}"), "[b]$1[/b]"],
+ [regex("[\\*]{2}(.*?)[\\*]{2}"), "[b]$1[/b]"],
+ # italic font
+ [regex("<i>(.*?)<\\/i>"), "[i]$1[/i]"],
+ [regex(exclude_font_size+"_(.*?)_"), "[i]$1[/i]"],
+ [regex("\\*(.*?)\\*"), "[i]$1[/i]"],
+
+ # strikethrough font
+ [regex("<s>(.*?)</s>"), "[s]$1[/s]"],
+ [regex("~~(.*?)~~"), "[s]$1[/s]"],
+ [regex("~(.*?)~"), "[s]$1[/s]"],
+
+ # handling lists
+ # using an image for dots
+ [regex("(?m)^[ ]{0,1}[*\\-+] (.*)$"), list_replace(0)],
+ [regex("(?m)^[ ]{2,3}[*\\-+] (.*)$"), list_replace(1)],
+ [regex("(?m)^[ ]{4,5}[*\\-+] (.*)$"), list_replace(2)],
+ [regex("(?m)^[ ]{6,7}[*\\-+] (.*)$"), list_replace(3)],
+ [regex("(?m)^[ ]{8,9}[*\\-+] (.*)$"), list_replace(4)],
+
+ # code
+ [regex("``([\\s\\S]*?)``"), code_block("$1")],
+ [regex("`([\\s\\S]*?)`{1,2}"), code_block("$1")],
+]
+
+var code_block_patterns := [
+ # code blocks, code blocks looks not like code blocks in richtext
+ [regex("```(javascript|python|shell|gdscript|gd)([\\s\\S]*?\n)```"), code_block("$2", true)],
+]
+
+var _img_replace_regex := RegEx.new()
+var _image_urls := PackedStringArray()
+var _on_table_tag := false
+var _client: GdUnitUpdateClient
+
+
+static func regex(pattern: String) -> RegEx:
+ var regex_ := RegEx.new()
+ var err := regex_.compile(pattern)
+ if err != OK:
+ push_error("error '%s' checked pattern '%s'" % [err, pattern])
+ return null
+ return regex_
+
+
+func _init() -> void:
+ @warning_ignore("return_value_discarded")
+ _img_replace_regex.compile("\\[img\\]((.*?))\\[/img\\]")
+
+
+func set_http_client(client: GdUnitUpdateClient) -> void:
+ _client = client
+
+
+@warning_ignore("return_value_discarded")
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE:
+ # finally remove_at the downloaded images
+ for image in _image_urls:
+ DirAccess.remove_absolute(image)
+ DirAccess.remove_absolute(image + ".import")
+
+
+func list_replace(indent: int) -> String:
+ var replace_pattern := "[img=12x12]res://addons/gdUnit4/src/update/assets/dot2.png[/img]" if indent %2 else "[img=12x12]res://addons/gdUnit4/src/update/assets/dot1.png[/img]"
+ replace_pattern += " $1"
+
+ for index in indent:
+ replace_pattern = replace_pattern.insert(0, " ")
+ return replace_pattern
+
+
+func code_block(replace: String, border: bool = false) -> String:
+ if border:
+ return """
+ [img=1400x14]res://addons/gdUnit4/src/update/assets/border_top.png[/img]
+ [indent][color=GRAY][font_size=16]%s[/font_size][/color][/indent]
+ [img=1400x14]res://addons/gdUnit4/src/update/assets/border_bottom.png[/img]
+ """.dedent() % replace
+ return "[code][bgcolor=DARK_SLATE_GRAY][color=GRAY][font_size=16]%s[/font_size][/color][/bgcolor][/code]" % replace
+
+
+func convert_text(input: String) -> String:
+ input = process_tables(input)
+
+ for pattern: Array in md_replace_patterns:
+ var regex_: RegEx = pattern[0]
+ var bb_replace: Variant = pattern[1]
+ if bb_replace is Callable:
+ @warning_ignore("unsafe_method_access")
+ input = await bb_replace.call(regex_, input)
+ else:
+ @warning_ignore("unsafe_cast")
+ input = regex_.sub(input, bb_replace as String, true)
+ return input
+
+
+func convert_code_block(input: String) -> String:
+ for pattern: Array in code_block_patterns:
+ var regex_: RegEx = pattern[0]
+ var bb_replace: Variant = pattern[1]
+ if bb_replace is Callable:
+ @warning_ignore("unsafe_method_access")
+ input = await bb_replace.call(regex_, input)
+ else:
+ @warning_ignore("unsafe_cast")
+ input = regex_.sub(input, bb_replace as String, true)
+ return input
+
+
+func to_bbcode(input: String) -> String:
+ var re := regex("(?m)```[\\s\\S]*?```")
+ var current_pos := 0
+ var as_bbcode := ""
+
+ # we split by code blocks to handle this blocks customized
+ for result in re.search_all(input):
+ # Add text before code block
+ if result.get_start() > current_pos:
+ as_bbcode += await convert_text(input.substr(current_pos, result.get_start() - current_pos))
+ # Add code block
+ as_bbcode += await convert_code_block(result.get_string())
+ current_pos = result.get_end()
+
+ # Add remaining text after last code block
+ if current_pos < input.length():
+ as_bbcode += await convert_text(input.substr(current_pos))
+ return as_bbcode
+
+
+func process_tables(input: String) -> String:
+ var bbcode := PackedStringArray()
+ var lines: Array[String] = Array(input.split("\n") as Array, TYPE_STRING, "", null)
+ while not lines.is_empty():
+ if is_table(lines[0]):
+ bbcode.append_array(parse_table(lines))
+ continue
+ @warning_ignore("return_value_discarded", "unsafe_cast")
+ bbcode.append(lines.pop_front() as String)
+ return "\n".join(bbcode)
+
+
+class GdUnitMDReaderTable:
+ var _columns: int
+ var _rows: Array[Row] = []
+
+ class Row:
+ var _cells := PackedStringArray()
+
+
+ func _init(cells: PackedStringArray, columns: int) -> void:
+ _cells = cells
+ for i in range(_cells.size(), columns):
+ @warning_ignore("return_value_discarded")
+ _cells.append("")
+
+
+ func to_bbcode(cell_sizes: PackedInt32Array, bold: bool) -> String:
+ var cells := PackedStringArray()
+ for cell_index in _cells.size():
+ var cell: String = _cells[cell_index]
+ if cell.strip_edges() == "--":
+ cell = create_line(cell_sizes[cell_index])
+ if bold:
+ cell = "[b]%s[/b]" % cell
+ @warning_ignore("return_value_discarded")
+ cells.append("[cell]%s[/cell]" % cell)
+ return "|".join(cells)
+
+
+ func create_line(length: int) -> String:
+ var line := ""
+ for i in length:
+ line += "-"
+ return line
+
+
+ func _init(columns: int) -> void:
+ _columns = columns
+
+
+ func parse_row(line :String) -> bool:
+ # is line containing cells?
+ if line.find("|") == -1:
+ return false
+ _rows.append(Row.new(line.split("|"), _columns))
+ return true
+
+
+ func calculate_max_cell_sizes() -> PackedInt32Array:
+ var cells_size := PackedInt32Array()
+ for column in _columns:
+ @warning_ignore("return_value_discarded")
+ cells_size.append(0)
+
+ for row_index in _rows.size():
+ var row: Row = _rows[row_index]
+ for cell_index in row._cells.size():
+ var cell_size: int = cells_size[cell_index]
+ var size := row._cells[cell_index].length()
+ if size > cell_size:
+ cells_size[cell_index] = size
+ return cells_size
+
+
+ @warning_ignore("return_value_discarded")
+ func to_bbcode() -> PackedStringArray:
+ var cell_sizes := calculate_max_cell_sizes()
+ var bb_code := PackedStringArray()
+
+ bb_code.append("[table=%d]" % _columns)
+ for row_index in _rows.size():
+ bb_code.append(_rows[row_index].to_bbcode(cell_sizes, row_index==0))
+ bb_code.append("[/table]\n")
+ return bb_code
+
+
+func parse_table(lines: Array) -> PackedStringArray:
+ var line: String = lines[0]
+ var table := GdUnitMDReaderTable.new(line.count("|") + 1)
+ while not lines.is_empty():
+ line = lines.pop_front()
+ if not table.parse_row(line):
+ break
+ return table.to_bbcode()
+
+
+func is_table(line: String) -> bool:
+ return line.find("|") != -1
+
+
+func open_table(line: String) -> String:
+ _on_table_tag = true
+ return "[table=%d]" % (line.count("|") + 1)
+
+
+func close_table() -> String:
+ _on_table_tag = false
+ return "[/table]"
+
+
+func extract_cells(line: String, bold := false) -> String:
+ var cells := ""
+ for cell in line.split("|"):
+ if bold:
+ cell = "[b]%s[/b]" % cell
+ cells += "[cell]%s[/cell]" % cell
+ return cells
+
+
+func process_image_references(p_regex: RegEx, p_input: String) -> String:
+ #return p_input
+
+ # exists references?
+ var matches := p_regex.search_all(p_input)
+ if matches.is_empty():
+ return p_input
+ # collect image references and remove_at it
+ var references := Dictionary()
+ var link_regex := regex("\\[(\\S+)\\]:(\\S+)([ ]\"(.*)\")?")
+ # create copy of original source to replace checked it
+ var input := p_input.replace("\r", "")
+ var extracted_references := p_input.replace("\r", "")
+ for reg_match in link_regex.search_all(input):
+ var line := reg_match.get_string(0) + "\n"
+ var ref := reg_match.get_string(1)
+ #var topl_tip = reg_match.get_string(4)
+ # collect reference and url
+ references[ref] = reg_match.get_string(2)
+ extracted_references = extracted_references.replace(line, "")
+
+ # replace image references by collected url's
+ for reference_key: String in references.keys():
+ var regex_key := regex("\\](\\[%s\\])" % reference_key)
+ for reg_match in regex_key.search_all(extracted_references):
+ var ref: String = reg_match.get_string(0)
+ var image_url: String = "](%s)" % references.get(reference_key)
+ extracted_references = extracted_references.replace(ref, image_url)
+ return extracted_references
+
+
+@warning_ignore("return_value_discarded")
+func process_image(p_regex: RegEx, p_input: String) -> String:
+ #return p_input
+ var to_replace := PackedStringArray()
+ var tool_tips := PackedStringArray()
+ # find all matches
+ var matches := p_regex.search_all(p_input)
+ if matches.is_empty():
+ return p_input
+ for reg_match in matches:
+ # grap the parts to replace and store temporay because a direct replace will distort the offsets
+ to_replace.append(p_input.substr(reg_match.get_start(0), reg_match.get_end(0)))
+ # grap optional tool tips
+ tool_tips.append(reg_match.get_string(5))
+ # finally replace all findings
+ for replace in to_replace:
+ var re := p_regex.sub(replace, "[img]$2[/img]")
+ p_input = p_input.replace(replace, re)
+ return await _process_external_image_resources(p_input)
+
+
+func _process_external_image_resources(input: String) -> String:
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(image_download_folder)
+ # scan all img for external resources and download it
+ for value in _img_replace_regex.search_all(input):
+ if value.get_group_count() >= 1:
+ var image_url: String = value.get_string(1)
+ # if not a local resource we need to download it
+ if image_url.begins_with("http"):
+ if OS.is_stdout_verbose():
+ prints("download image:", image_url)
+ var response := await _client.request_image(image_url)
+ if response.status() == 200:
+ var image := Image.new()
+ var error := image.load_png_from_buffer(response.get_body())
+ if error != OK:
+ prints("Error creating image from response", error)
+ # replace characters where format characters
+ var new_url := image_download_folder + image_url.get_file().replace("_", "-")
+ if new_url.get_extension() != 'png':
+ new_url = new_url + '.png'
+ var err := image.save_png(new_url)
+ if err:
+ push_error("Can't save image to '%s'. Error: %s" % [new_url, error_string(err)])
+ @warning_ignore("return_value_discarded")
+ _image_urls.append(new_url)
+ input = input.replace(image_url, new_url)
+ return input
diff --git a/addons/gdUnit4/src/update/GdMarkDownReader.gd.uid b/addons/gdUnit4/src/update/GdMarkDownReader.gd.uid
new file mode 100644
index 0000000..6817573
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdMarkDownReader.gd.uid
@@ -0,0 +1 @@
+uid://d1dpjlnngiokr
diff --git a/addons/gdUnit4/src/update/GdUnitPatch.gd b/addons/gdUnit4/src/update/GdUnitPatch.gd
new file mode 100644
index 0000000..daa20f7
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitPatch.gd
@@ -0,0 +1,20 @@
+class_name GdUnitPatch
+extends RefCounted
+
+const PATCH_VERSION = "patch_version"
+
+var _version :GdUnit4Version
+
+
+func _init(version_ :GdUnit4Version) -> void:
+ _version = version_
+
+
+func version() -> GdUnit4Version:
+ return _version
+
+
+# this function needs to be implement
+func execute() -> bool:
+ push_error("The function 'execute()' is not implemented at %s" % self)
+ return false
diff --git a/addons/gdUnit4/src/update/GdUnitPatch.gd.uid b/addons/gdUnit4/src/update/GdUnitPatch.gd.uid
new file mode 100644
index 0000000..ca688a3
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitPatch.gd.uid
@@ -0,0 +1 @@
+uid://b0il3gifqhk77
diff --git a/addons/gdUnit4/src/update/GdUnitPatcher.gd b/addons/gdUnit4/src/update/GdUnitPatcher.gd
new file mode 100644
index 0000000..73d25c9
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitPatcher.gd
@@ -0,0 +1,75 @@
+class_name GdUnitPatcher
+extends RefCounted
+
+
+const _base_dir := "res://addons/gdUnit4/src/update/patches/"
+
+var _patches := Dictionary()
+
+
+func scan(current :GdUnit4Version) -> void:
+ _scan(_base_dir, current)
+
+
+func _scan(scan_path :String, current :GdUnit4Version) -> void:
+ _patches = Dictionary()
+ var patch_paths := _collect_patch_versions(scan_path, current)
+ for path in patch_paths:
+ prints("scan for patches checked '%s'" % path)
+ _patches[path] = _scan_patches(path)
+
+
+func patch_count() -> int:
+ var count := 0
+ for key :String in _patches.keys():
+ @warning_ignore("unsafe_method_access")
+ count += _patches[key].size()
+ return count
+
+
+func execute() -> void:
+ for key :String in _patches.keys():
+ for path :String in _patches[key]:
+ var patch :GdUnitPatch = (load(key + "/" + path) as GDScript).new()
+ if patch:
+ prints("execute patch", patch.version(), patch.get_script().resource_path)
+ if not patch.execute():
+ prints("error checked execution patch %s" % key + "/" + path)
+
+
+func _collect_patch_versions(scan_path :String, current :GdUnit4Version) -> PackedStringArray:
+ if not DirAccess.dir_exists_absolute(scan_path):
+ return PackedStringArray()
+ var patches := Array()
+ var dir := DirAccess.open(scan_path)
+ if dir != null:
+ @warning_ignore("return_value_discarded")
+ dir.list_dir_begin() # TODO GODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
+ var next := "."
+ while next != "":
+ next = dir.get_next()
+ if next.is_empty() or next == "." or next == "..":
+ continue
+ var version := GdUnit4Version.parse(next)
+ if version.is_greater(current):
+ patches.append(scan_path + next)
+ patches.sort()
+ return PackedStringArray(patches)
+
+
+func _scan_patches(path :String) -> PackedStringArray:
+ var patches := Array()
+ var dir := DirAccess.open(path)
+ if dir != null:
+ @warning_ignore("return_value_discarded")
+ dir.list_dir_begin() # TODOGODOT4 fill missing arguments https://github.com/godotengine/godot/pull/40547
+ var next := "."
+ while next != "":
+ next = dir.get_next()
+ # step over directory links and .uid files
+ if next.is_empty() or next == "." or next == ".." or next.ends_with(".uid"):
+ continue
+ patches.append(next)
+ # make sorted from lowest to high version
+ patches.sort()
+ return PackedStringArray(patches)
diff --git a/addons/gdUnit4/src/update/GdUnitPatcher.gd.uid b/addons/gdUnit4/src/update/GdUnitPatcher.gd.uid
new file mode 100644
index 0000000..241850e
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitPatcher.gd.uid
@@ -0,0 +1 @@
+uid://b81y3i61xqoiw
diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.gd b/addons/gdUnit4/src/update/GdUnitUpdate.gd
new file mode 100644
index 0000000..97b7fdd
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdate.gd
@@ -0,0 +1,305 @@
+@tool
+extends Container
+
+const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
+const GdUnitUpdateClient := preload("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd")
+const GDUNIT_TEMP := "user://tmp"
+
+@onready var _progress_content: RichTextLabel = %message
+@onready var _progress_bar: TextureProgressBar = %progress
+@onready var _cancel_btn: Button = %cancel
+@onready var _update_btn: Button = %update
+@onready var _spinner_img := GdUnitUiTools.get_spinner()
+
+
+var _debug_mode := false
+var _update_client :GdUnitUpdateClient
+var _download_url :String
+
+
+func _ready() -> void:
+ init_progress(6)
+
+
+func _process(_delta :float) -> void:
+ if _progress_content != null and _progress_content.is_visible_in_tree():
+ _progress_content.queue_redraw()
+
+
+func init_progress(max_value: int) -> void:
+ _cancel_btn.disabled = false
+ _update_btn.disabled = false
+ _progress_bar.max_value = max_value
+ _progress_bar.value = 1
+ message_h4("Press [Update] to start.", Color.GREEN, false)
+
+
+func setup(update_client: GdUnitUpdateClient, download_url: String) -> void:
+ _update_client = update_client
+ _download_url = download_url
+
+
+func update_progress(message: String, color := Color.GREEN) -> void:
+ message_h4(message, color)
+ _progress_bar.value += 1
+ if _debug_mode:
+ await get_tree().create_timer(3).timeout
+ await get_tree().create_timer(.2).timeout
+
+
+func _colored(message: String, color: Color) -> String:
+ return "[color=#%s]%s[/color]" % [color.to_html(), message]
+
+
+func message_h4(message: String, color: Color, show_spinner := true) -> void:
+ _progress_content.clear()
+ if show_spinner:
+ _progress_content.add_image(_spinner_img)
+ _progress_content.append_text(" [font_size=16]%s[/font_size]" % _colored(message, color))
+ if _debug_mode:
+ prints(message)
+
+
+@warning_ignore("return_value_discarded")
+func run_update() -> void:
+ _cancel_btn.disabled = true
+ _update_btn.disabled = true
+
+ await update_progress("Downloading the update.")
+ await download_release()
+ await update_progress("Extracting")
+ var zip_file := temp_dir() + "/update.zip"
+ var tmp_path := create_temp_dir("update")
+ var result :Variant = extract_zip(zip_file, tmp_path)
+ if result == null:
+ await update_progress("Update failed! .. Rollback.", Color.INDIAN_RED)
+ await get_tree().create_timer(3).timeout
+ _cancel_btn.disabled = false
+ _update_btn.disabled = false
+ init_progress(5)
+ hide()
+ return
+
+ await update_progress("Uninstall GdUnit4.")
+ disable_gdUnit()
+ if not _debug_mode:
+ GdUnitFileAccess.delete_directory("res://addons/gdUnit4/")
+ # give editor time to react on deleted files
+ await get_tree().create_timer(1).timeout
+
+ await update_progress("Install new GdUnit4 version.")
+ if _debug_mode:
+ copy_directory(tmp_path, "res://debug")
+ else:
+ copy_directory(tmp_path, "res://")
+
+ await update_progress("Patch invalid UID's")
+ await patch_uids()
+
+ await rebuild_project()
+
+ await update_progress("New GdUnit version successfully installed, Restarting Godot please wait.")
+ await get_tree().create_timer(3).timeout
+ enable_gdUnit()
+ hide()
+ GdUnitFileAccess.delete_directory("res://addons/.gdunit_update")
+ restart_godot()
+
+
+func patch_uids(path := "res://addons/gdUnit4/src/") -> void:
+ var to_reimport: PackedStringArray
+ for file in DirAccess.get_files_at(path):
+ var file_path := path.path_join(file)
+ var ext := file.get_extension()
+
+ if ext == "tscn" or ext == "scn" or ext == "tres" or ext == "res":
+ message_h4("Patch GdUnit4 scene: '%s'" % file, Color.WEB_GREEN)
+ remove_uids_from_file(file_path)
+ elif FileAccess.file_exists(file_path + ".import"):
+ to_reimport.append(file_path)
+
+ if not to_reimport.is_empty():
+ message_h4("Reimport resources '%s'" % ", ".join(to_reimport), Color.WEB_GREEN)
+ if Engine.is_editor_hint():
+ EditorInterface.get_resource_filesystem().reimport_files(to_reimport)
+
+ for dir in DirAccess.get_directories_at(path):
+ if not dir.begins_with("."):
+ patch_uids(path.path_join(dir))
+ await get_tree().process_frame
+
+
+func remove_uids_from_file(file_path: String) -> bool:
+ var file := FileAccess.open(file_path, FileAccess.READ)
+ if file == null:
+ print("Failed to open file: ", file_path)
+ return false
+
+ var original_content := file.get_as_text()
+ file.close()
+
+ # Remove UIDs using regex
+ var regex := RegEx.new()
+ regex.compile("(\\[ext_resource[^\\]]*?)\\s+uid=\"uid://[^\"]*\"")
+
+ var modified_content := regex.sub(original_content, "$1", true)
+
+ # Check if any changes were made
+ if original_content != modified_content:
+ prints("Patched invalid uid's out in '%s'" % file_path)
+ # Write the modified content back
+ file = FileAccess.open(file_path, FileAccess.WRITE)
+ if file == null:
+ print("Failed to write to file: ", file_path)
+ return false
+
+ file.store_string(modified_content)
+ file.close()
+ return true
+
+ return false
+
+
+func restart_godot() -> void:
+ prints("Force restart Godot")
+ EditorInterface.restart_editor(true)
+
+
+@warning_ignore("return_value_discarded")
+func enable_gdUnit() -> void:
+ var enabled_plugins := PackedStringArray()
+ if ProjectSettings.has_setting("editor_plugins/enabled"):
+ enabled_plugins = ProjectSettings.get_setting("editor_plugins/enabled")
+ if not enabled_plugins.has("res://addons/gdUnit4/plugin.cfg"):
+ enabled_plugins.append("res://addons/gdUnit4/plugin.cfg")
+ ProjectSettings.set_setting("editor_plugins/enabled", enabled_plugins)
+ ProjectSettings.save()
+
+
+func disable_gdUnit() -> void:
+ EditorInterface.set_plugin_enabled("gdUnit4", false)
+
+
+func temp_dir() -> String:
+ if not DirAccess.dir_exists_absolute(GDUNIT_TEMP):
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(GDUNIT_TEMP)
+ return GDUNIT_TEMP
+
+
+func create_temp_dir(folder_name :String) -> String:
+ var new_folder := temp_dir() + "/" + folder_name
+ GdUnitFileAccess.delete_directory(new_folder)
+ if not DirAccess.dir_exists_absolute(new_folder):
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(new_folder)
+ return new_folder
+
+
+func copy_directory(from_dir: String, to_dir: String) -> bool:
+ if not DirAccess.dir_exists_absolute(from_dir):
+ printerr("Source directory not found '%s'" % from_dir)
+ return false
+ # check if destination exists
+ if not DirAccess.dir_exists_absolute(to_dir):
+ # create it
+ var err := DirAccess.make_dir_recursive_absolute(to_dir)
+ if err != OK:
+ printerr("Can't create directory '%s'. Error: %s" % [to_dir, error_string(err)])
+ return false
+ var source_dir := DirAccess.open(from_dir)
+ var dest_dir := DirAccess.open(to_dir)
+ if source_dir != null:
+ @warning_ignore("return_value_discarded")
+ source_dir.list_dir_begin()
+ var next := "."
+
+ while next != "":
+ next = source_dir.get_next()
+ if next == "" or next == "." or next == "..":
+ continue
+ var source := source_dir.get_current_dir() + "/" + next
+ var dest := dest_dir.get_current_dir() + "/" + next
+ if source_dir.current_is_dir():
+ @warning_ignore("return_value_discarded")
+ copy_directory(source + "/", dest)
+ continue
+ var err := source_dir.copy(source, dest)
+ if err != OK:
+ printerr("Error checked copy file '%s' to '%s'" % [source, dest])
+ return false
+ return true
+ else:
+ printerr("Directory not found: " + from_dir)
+ return false
+
+
+func extract_zip(zip_package: String, dest_path: String) -> Variant:
+ var zip: ZIPReader = ZIPReader.new()
+ var err := zip.open(zip_package)
+ if err != OK:
+ printerr("Extracting `%s` failed! Please collect the error log and report this. Error Code: %s" % [zip_package, err])
+ return null
+ var zip_entries: PackedStringArray = zip.get_files()
+ # Get base path and step over archive folder
+ var archive_path := zip_entries[0]
+ zip_entries.remove_at(0)
+
+ for zip_entry in zip_entries:
+ var new_file_path: String = dest_path + "/" + zip_entry.replace(archive_path, "")
+ if zip_entry.ends_with("/"):
+ @warning_ignore("return_value_discarded")
+ DirAccess.make_dir_recursive_absolute(new_file_path)
+ continue
+ var file: FileAccess = FileAccess.open(new_file_path, FileAccess.WRITE)
+ file.store_buffer(zip.read_file(zip_entry))
+ @warning_ignore("return_value_discarded")
+ zip.close()
+ return dest_path
+
+
+func download_release() -> void:
+ var zip_file := GdUnitFileAccess.temp_dir() + "/update.zip"
+ var response :GdUnitUpdateClient.HttpResponse
+ if _debug_mode:
+ response = GdUnitUpdateClient.HttpResponse.new(200, PackedByteArray())
+ zip_file = "res://update.zip"
+ return
+
+ response = await _update_client.request_zip_package(_download_url, zip_file)
+ if response.status() != 200:
+ push_warning("Update information cannot be retrieved from GitHub! \n Error code: %d : %s" % [response.status(), response.response()])
+ message_h4("Download the update failed! Try it later again.", Color.INDIAN_RED)
+ await get_tree().create_timer(3).timeout
+
+
+func rebuild_project() -> void:
+ # Check if this is a Godot .NET runtime instance
+ if not ClassDB.class_exists("CSharpScript"):
+ return
+
+ update_progress("Rebuild the project ...")
+ await get_tree().process_frame
+
+ var output := []
+ var exit_code := OS.execute("dotnet", ["build"], output)
+ if exit_code == -1:
+ message_h4("Rebuild the project failed, check your project dependencies.", Color.INDIAN_RED)
+ await get_tree().create_timer(3).timeout
+ return
+
+ for out: String in output:
+ print_rich("[color=DEEP_SKY_BLUE] %s" % out.strip_edges())
+ await get_tree().process_frame
+
+
+func _on_confirmed() -> void:
+ await run_update()
+
+
+func _on_cancel_pressed() -> void:
+ hide()
+
+
+func _on_update_pressed() -> void:
+ await run_update()
diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.gd.uid b/addons/gdUnit4/src/update/GdUnitUpdate.gd.uid
new file mode 100644
index 0000000..a72d4a5
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdate.gd.uid
@@ -0,0 +1 @@
+uid://dyr407n7f3td6
diff --git a/addons/gdUnit4/src/update/GdUnitUpdate.tscn b/addons/gdUnit4/src/update/GdUnitUpdate.tscn
new file mode 100644
index 0000000..dc0511a
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdate.tscn
@@ -0,0 +1,100 @@
+[gd_scene format=3 uid="uid://2eahgaw88y6q"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/update/GdUnitUpdate.gd" id="1"]
+
+[sub_resource type="Gradient" id="Gradient_wilsr"]
+colors = PackedColorArray(0.151276, 0.151276, 0.151276, 1, 1, 1, 1, 1)
+
+[sub_resource type="GradientTexture2D" id="GradientTexture2D_45cww"]
+gradient = SubResource("Gradient_wilsr")
+fill_to = Vector2(0.75641, 0)
+
+[sub_resource type="Gradient" id="Gradient_i0qp8"]
+colors = PackedColorArray(1, 1, 1, 1, 0.20871, 0.20871, 0.20871, 1)
+
+[sub_resource type="GradientTexture2D" id="GradientTexture2D_wilsr"]
+gradient = SubResource("Gradient_i0qp8")
+fill_from = Vector2(0.794872, 0)
+fill_to = Vector2(0, 0)
+
+[node name="GdUnitUpdate" type="MarginContainer"]
+clip_contents = true
+custom_minimum_size = Vector2(0, 80)
+anchors_preset = 10
+anchor_right = 1.0
+offset_bottom = 80.0
+grow_horizontal = 2
+size_flags_horizontal = 3
+theme_override_constants/margin_left = 10
+theme_override_constants/margin_right = 10
+script = ExtResource("1")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+layout_mode = 2
+
+[node name="Panel" type="Panel" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="message" type="RichTextLabel" parent="VBoxContainer/Panel"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+bbcode_enabled = true
+text = "aaaaa"
+fit_content = true
+scroll_active = false
+shortcut_keys_enabled = false
+
+[node name="Panel2" type="Panel" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="progress" type="TextureProgressBar" parent="VBoxContainer/Panel2"]
+unique_name_in_owner = true
+auto_translate_mode = 2
+clip_contents = true
+custom_minimum_size = Vector2(0, 20)
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+localize_numeral_system = false
+min_value = 1.0
+max_value = 5.0
+value = 1.0
+rounded = true
+allow_greater = true
+nine_patch_stretch = true
+texture_under = SubResource("GradientTexture2D_45cww")
+texture_progress = SubResource("GradientTexture2D_wilsr")
+tint_under = Color(0.0235294, 0.145098, 0.168627, 1)
+tint_progress = Color(0.288912, 0.233442, 0.533772, 1)
+
+[node name="PanelContainer" type="MarginContainer" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/PanelContainer"]
+layout_mode = 2
+theme_override_constants/separation = 10
+alignment = 2
+
+[node name="update" type="Button" parent="VBoxContainer/PanelContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Update"
+
+[node name="cancel" type="Button" parent="VBoxContainer/PanelContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+text = "Cancel"
+
+[connection signal="pressed" from="VBoxContainer/PanelContainer/HBoxContainer/update" to="." method="_on_update_pressed"]
+[connection signal="pressed" from="VBoxContainer/PanelContainer/HBoxContainer/cancel" to="." method="_on_cancel_pressed"]
diff --git a/addons/gdUnit4/src/update/GdUnitUpdateClient.gd b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd
new file mode 100644
index 0000000..b4f54b7
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd
@@ -0,0 +1,98 @@
+@tool
+extends Node
+
+signal request_completed(response: HttpResponse)
+
+class HttpResponse:
+ var _http_status: int
+ var _body: PackedByteArray
+
+
+ func _init(http_status: int, body: PackedByteArray) -> void:
+ _http_status = http_status
+ _body = body
+
+
+ func status() -> int:
+ return _http_status
+
+
+ func response() -> Variant:
+ if _http_status != 200:
+ return _body.get_string_from_utf8()
+
+ var test_json_conv := JSON.new()
+ @warning_ignore("return_value_discarded")
+ var error := test_json_conv.parse(_body.get_string_from_utf8())
+ if error != OK:
+ return "HttpResponse: %s Error: %s" % [error_string(error), _body.get_string_from_utf8()]
+ return test_json_conv.get_data()
+
+ func get_body() -> PackedByteArray:
+ return _body
+
+
+var _http_request := HTTPRequest.new()
+
+
+func _ready() -> void:
+ add_child(_http_request)
+ @warning_ignore("return_value_discarded")
+ _http_request.request_completed.connect(_on_request_completed)
+
+
+func _notification(what: int) -> void:
+ if what == NOTIFICATION_PREDELETE:
+ if is_instance_valid(_http_request):
+ _http_request.queue_free()
+
+
+#func list_tags() -> void:
+# _http_request.connect("request_completed",Callable(self,"_response_request_tags"))
+# var error = _http_request.request("https://api.github.com/repos/MikeSchulze/gdUnit4/tags")
+# if error != OK:
+# push_error("An error occurred in the HTTP request.")
+
+
+func request_latest_version() -> HttpResponse:
+ var error := _http_request.request("https://api.github.com/repos/MikeSchulze/gdUnit4/tags")
+ if error != OK:
+ var message := "Request latest version failed, %s" % error_string(error)
+ return HttpResponse.new(error, message.to_utf8_buffer())
+ return await self.request_completed
+
+
+func request_releases() -> HttpResponse:
+ var error := _http_request.request("https://api.github.com/repos/MikeSchulze/gdUnit4/releases")
+ if error != OK:
+ var message := "request_releases failed: %d" % error
+ return HttpResponse.new(error, message.to_utf8_buffer())
+ return await self.request_completed
+
+
+func request_image(url: String) -> HttpResponse:
+ var error := _http_request.request(url)
+ if error != OK:
+ var message := "request_image failed: %d" % error
+ return HttpResponse.new(error, message.to_utf8_buffer())
+ return await self.request_completed
+
+
+func request_zip_package(url: String, file: String) -> HttpResponse:
+ _http_request.set_download_file(file)
+ var error := _http_request.request(url)
+ if error != OK:
+ var message := "request_zip_package failed: %d" % error
+ return HttpResponse.new(error, message.to_utf8_buffer())
+ return await self.request_completed
+
+
+func extract_latest_version(response: HttpResponse) -> GdUnit4Version:
+ var body: Array = response.response()
+ return GdUnit4Version.parse(str(body[0]["name"]))
+
+
+func _on_request_completed(_result: int, response_http_status: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
+ if _http_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
+ _http_request.set_download_file("")
+ request_completed.emit(HttpResponse.new(response_http_status, body))
diff --git a/addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid
new file mode 100644
index 0000000..5b6e2d4
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateClient.gd.uid
@@ -0,0 +1 @@
+uid://c0aqh8gtyaxls
diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd
new file mode 100644
index 0000000..f4ae9ac
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd
@@ -0,0 +1,206 @@
+@tool
+extends MarginContainer
+
+#signal request_completed(response)
+
+const GdMarkDownReader = preload("res://addons/gdUnit4/src/update/GdMarkDownReader.gd")
+const GdUnitUpdateClient = preload("res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd")
+const GdUnitUpdateProgress = preload("res://addons/gdUnit4/src/update/GdUnitUpdate.gd")
+
+@onready var _md_reader: GdMarkDownReader = GdMarkDownReader.new()
+@onready var _update_client: GdUnitUpdateClient = $GdUnitUpdateClient
+@onready var _header: Label = $Panel/GridContainer/PanelContainer/header
+@onready var _update_button: Button = $Panel/GridContainer/Panel/HBoxContainer/update
+@onready var _content: RichTextLabel = $Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer/content
+@onready var _update_progress :GdUnitUpdateProgress = %update_banner
+
+var _debug_mode := false
+var _patcher := GdUnitPatcher.new()
+var _current_version := GdUnit4Version.current()
+
+
+func _ready() -> void:
+ _update_button.set_disabled(false)
+ _md_reader.set_http_client(_update_client)
+ @warning_ignore("return_value_discarded")
+ #GdUnitFonts.init_fonts(_content)
+ _update_progress.set_visible(false)
+ _update_progress.hidden.connect(func() -> void:
+ _update_button.set_disabled(false)
+ )
+
+
+func request_releases() -> bool:
+ if _debug_mode:
+ _update_progress._debug_mode = _debug_mode
+ _header.text = "A new version 'v4.4.4' is available"
+ _update_button.set_disabled(false)
+ return true
+
+ var response :GdUnitUpdateClient.HttpResponse = await _update_client.request_latest_version()
+ if response.status() != 200:
+ _header.text = "Update information cannot be retrieved from GitHub!"
+ message_h4("\n\nError: %s" % response.response(), Color.INDIAN_RED)
+ return false
+ var latest_version := _update_client.extract_latest_version(response)
+ # if same version exit here no update need
+ if latest_version.is_greater(_current_version):
+ _patcher.scan(_current_version)
+ _header.text = "A new version '%s' is available" % latest_version
+ var download_zip_url := extract_zip_url(response)
+ _update_progress.setup(_update_client, download_zip_url)
+ _update_button.set_disabled(false)
+ return true
+ else:
+ _header.text = "No update is available."
+ _update_button.set_disabled(true)
+ return false
+
+
+func _colored(message_: String, color: Color) -> String:
+ return "[color=#%s]%s[/color]" % [color.to_html(), message_]
+
+
+func message_h4(message_: String, color: Color, clear := true) -> void:
+ if clear:
+ _content.clear()
+ _content.append_text("[font_size=16]%s[/font_size]" % _colored(message_, color))
+
+
+func message(message_: String, color: Color) -> void:
+ _content.clear()
+ _content.append_text(_colored(message_, color))
+
+
+func _process(_delta: float) -> void:
+ if _content != null and _content.is_visible_in_tree():
+ _content.queue_redraw()
+
+
+func show_update() -> void:
+ if not GdUnitSettings.is_update_notification_enabled():
+ _header.text = "No update is available."
+ message_h4("The search for updates is deactivated.", Color.CORNFLOWER_BLUE)
+ _update_button.set_disabled(true)
+ return
+
+ if not await request_releases():
+ return
+ _update_button.set_disabled(true)
+
+ prints("Scan for GdUnit4 Update ...")
+ message_h4("\n\n\nRequest release infos ... ", Color.SNOW)
+ _content.add_image(GdUnitUiTools.get_spinner(), 32, 32)
+
+ var content: String
+ if _debug_mode:
+ await get_tree().create_timer(.2).timeout
+ var template := FileAccess.open("res://addons/gdUnit4/test/update/resources/http_response_releases.txt", FileAccess.READ).get_as_text()
+ content = await _md_reader.to_bbcode(template)
+ else:
+ var response :GdUnitUpdateClient.HttpResponse = await _update_client.request_releases()
+ if response.status() == 200:
+ content = await extract_releases(response, _current_version)
+ else:
+ message_h4("\n\n\nError checked request available releases!", Color.INDIAN_RED)
+ return
+
+ # finally force rescan to import images as textures
+ if Engine.is_editor_hint():
+ await rescan()
+ message(content, Color.CADET_BLUE)
+ _update_button.set_disabled(false)
+
+
+
+func extract_zip_url(response: GdUnitUpdateClient.HttpResponse) -> String:
+ var body :Array = response.response()
+ return body[0]["zipball_url"]
+
+
+func extract_releases(response: GdUnitUpdateClient.HttpResponse, current_version: GdUnit4Version) -> String:
+ await get_tree().process_frame
+ var result := ""
+ for release :Dictionary in response.response():
+ var release_version := str(release["tag_name"])
+ if GdUnit4Version.parse(release_version).equals(current_version):
+ break
+ var release_description := _colored("<h1>GdUnit Release %s</h1>" % release_version, Color.CORNFLOWER_BLUE)
+ release_description += "\n"
+ release_description += release["body"]
+ release_description += "\n\n"
+ result += await _md_reader.to_bbcode(release_description)
+ return result
+
+
+func rescan() -> void:
+ if Engine.is_editor_hint():
+ if OS.is_stdout_verbose():
+ prints(".. reimport release resources")
+ var fs := EditorInterface.get_resource_filesystem()
+ fs.scan()
+ while fs.is_scanning():
+ if OS.is_stdout_verbose():
+ progressBar(fs.get_scanning_progress() * 100 as int)
+ await get_tree().process_frame
+ await get_tree().process_frame
+ await get_tree().create_timer(1).timeout
+
+
+func progressBar(p_progress: int) -> void:
+ if p_progress < 0:
+ p_progress = 0
+ if p_progress > 100:
+ p_progress = 100
+ printraw("scan [%-50s] %-3d%%\r" % ["".lpad(int(p_progress/2.0), "#").rpad(50, "-"), p_progress])
+
+
+@warning_ignore("return_value_discarded")
+func _on_update_pressed() -> void:
+ _update_button.set_disabled(true)
+ # close all opend scripts before start the update
+ if not _debug_mode:
+ ScriptEditorControls.close_open_editor_scripts()
+ # copy update source to a temp because the update is deleting the whole gdUnit folder
+ DirAccess.make_dir_absolute("res://addons/.gdunit_update")
+ DirAccess.copy_absolute("res://addons/gdUnit4/src/update/GdUnitUpdate.tscn", "res://addons/.gdunit_update/GdUnitUpdate.tscn")
+ DirAccess.copy_absolute("res://addons/gdUnit4/src/update/GdUnitUpdate.gd", "res://addons/.gdunit_update/GdUnitUpdate.gd")
+ var source := FileAccess.open("res://addons/gdUnit4/src/update/GdUnitUpdate.tscn", FileAccess.READ)
+ var content := source.get_as_text().replace("res://addons/gdUnit4/src/update/GdUnitUpdate.gd", "res://addons/.gdunit_update/GdUnitUpdate.gd")
+ var dest := FileAccess.open("res://addons/.gdunit_update/GdUnitUpdate.tscn", FileAccess.WRITE)
+ dest.store_string(content)
+ _update_progress.set_visible(true)
+
+
+func _on_show_next_toggled(enabled: bool) -> void:
+ GdUnitSettings.set_update_notification(enabled)
+
+
+func _on_cancel_pressed() -> void:
+ hide()
+
+
+func _on_content_meta_clicked(meta: String) -> void:
+ var properties: Dictionary = str_to_var(meta)
+ if properties.has("url"):
+ @warning_ignore("return_value_discarded")
+ OS.shell_open(str(properties.get("url")))
+
+
+func _on_content_meta_hover_started(meta: String) -> void:
+ var properties: Dictionary = str_to_var(meta)
+ if properties.has("tool_tip"):
+ _content.set_tooltip_text(str(properties.get("tool_tip")))
+
+
+@warning_ignore("unused_parameter")
+func _on_content_meta_hover_ended(meta: String) -> void:
+ _content.set_tooltip_text("")
+
+
+func _on_visibility_changed() -> void:
+ if not is_visible_in_tree():
+ return
+ if _update_progress != null:
+ _update_progress.set_visible(false)
+ await show_update()
diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid
new file mode 100644
index 0000000..fa36885
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateNotify.gd.uid
@@ -0,0 +1 @@
+uid://ciyg27plnsvyr
diff --git a/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn b/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn
new file mode 100644
index 0000000..7305f61
--- /dev/null
+++ b/addons/gdUnit4/src/update/GdUnitUpdateNotify.tscn
@@ -0,0 +1,97 @@
+[gd_scene format=3 uid="uid://0xyeci1tqebj"]
+
+[ext_resource type="Script" path="res://addons/gdUnit4/src/update/GdUnitUpdateNotify.gd" id="1_112wo"]
+[ext_resource type="Script" path="res://addons/gdUnit4/src/update/GdUnitUpdateClient.gd" id="2_18asx"]
+[ext_resource type="PackedScene" path="res://addons/gdUnit4/src/update/GdUnitUpdate.tscn" id="3_x87h6"]
+
+[node name="Control" type="MarginContainer"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+script = ExtResource("1_112wo")
+
+[node name="GdUnitUpdateClient" type="Node" parent="."]
+script = ExtResource("2_18asx")
+
+[node name="Panel" type="Panel" parent="."]
+layout_mode = 2
+
+[node name="GridContainer" type="VBoxContainer" parent="Panel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+alignment = 1
+
+[node name="PanelContainer" type="MarginContainer" parent="Panel/GridContainer"]
+layout_mode = 2
+theme_override_constants/margin_left = 4
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 4
+theme_override_constants/margin_bottom = 4
+
+[node name="header" type="Label" parent="Panel/GridContainer/PanelContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 9
+
+[node name="PanelContainer2" type="PanelContainer" parent="Panel/GridContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="ScrollContainer" type="ScrollContainer" parent="Panel/GridContainer/PanelContainer2"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="MarginContainer" type="MarginContainer" parent="Panel/GridContainer/PanelContainer2/ScrollContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="content" type="RichTextLabel" parent="Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+bbcode_enabled = true
+
+[node name="update_banner" parent="Panel/GridContainer" instance=ExtResource("3_x87h6")]
+unique_name_in_owner = true
+visible = false
+layout_mode = 2
+size_flags_horizontal = 1
+size_flags_vertical = 8
+
+[node name="Panel" type="MarginContainer" parent="Panel/GridContainer"]
+layout_mode = 2
+size_flags_vertical = 8
+theme_override_constants/margin_left = 4
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 4
+theme_override_constants/margin_bottom = 4
+
+[node name="HBoxContainer" type="HBoxContainer" parent="Panel/GridContainer/Panel"]
+use_parent_material = true
+layout_mode = 2
+theme_override_constants/separation = 4
+
+[node name="update" type="Button" parent="Panel/GridContainer/Panel/HBoxContainer"]
+custom_minimum_size = Vector2(100, 40)
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 4
+text = "Update"
+
+[connection signal="visibility_changed" from="." to="." method="_on_visibility_changed"]
+[connection signal="meta_clicked" from="Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer/content" to="." method="_on_content_meta_clicked"]
+[connection signal="meta_hover_ended" from="Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer/content" to="." method="_on_content_meta_hover_ended"]
+[connection signal="meta_hover_started" from="Panel/GridContainer/PanelContainer2/ScrollContainer/MarginContainer/content" to="." method="_on_content_meta_hover_started"]
+[connection signal="pressed" from="Panel/GridContainer/Panel/HBoxContainer/update" to="." method="_on_update_pressed"]
diff --git a/addons/gdUnit4/src/update/assets/border_bottom.png b/addons/gdUnit4/src/update/assets/border_bottom.png
new file mode 100644
index 0000000..aa16bb7
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/border_bottom.png
Binary files differ
diff --git a/addons/gdUnit4/src/update/assets/border_bottom.png.import b/addons/gdUnit4/src/update/assets/border_bottom.png.import
new file mode 100644
index 0000000..752bf40
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/border_bottom.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dns6mtm6xif3"
+path="res://.godot/imported/border_bottom.png-30d66a4c67e3a03ad191e37cdf16549d.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/update/assets/border_bottom.png"
+dest_files=["res://.godot/imported/border_bottom.png-30d66a4c67e3a03ad191e37cdf16549d.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/gdUnit4/src/update/assets/border_top.png b/addons/gdUnit4/src/update/assets/border_top.png
new file mode 100644
index 0000000..b1b1039
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/border_top.png
Binary files differ
diff --git a/addons/gdUnit4/src/update/assets/border_top.png.import b/addons/gdUnit4/src/update/assets/border_top.png.import
new file mode 100644
index 0000000..dae66df
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/border_top.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://cvg6r1fdk1p01"
+path="res://.godot/imported/border_top.png-c47cbebdb755144731c6ae309e18bbaa.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/update/assets/border_top.png"
+dest_files=["res://.godot/imported/border_top.png-c47cbebdb755144731c6ae309e18bbaa.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/gdUnit4/src/update/assets/dot1.png b/addons/gdUnit4/src/update/assets/dot1.png
new file mode 100644
index 0000000..d5ea77b
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/dot1.png
Binary files differ
diff --git a/addons/gdUnit4/src/update/assets/dot1.png.import b/addons/gdUnit4/src/update/assets/dot1.png.import
new file mode 100644
index 0000000..a7f74d7
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/dot1.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://t4fmqmj3ffcd"
+path="res://.godot/imported/dot1.png-380baf1b5247addda93bce3c799aa4e7.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/update/assets/dot1.png"
+dest_files=["res://.godot/imported/dot1.png-380baf1b5247addda93bce3c799aa4e7.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/gdUnit4/src/update/assets/dot2.png b/addons/gdUnit4/src/update/assets/dot2.png
new file mode 100644
index 0000000..4a74498
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/dot2.png
Binary files differ
diff --git a/addons/gdUnit4/src/update/assets/dot2.png.import b/addons/gdUnit4/src/update/assets/dot2.png.import
new file mode 100644
index 0000000..8b5b001
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/dot2.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://c2usgitbic18b"
+path="res://.godot/imported/dot2.png-86a9db80ef4413e353c4339ad8f68a5f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/update/assets/dot2.png"
+dest_files=["res://.godot/imported/dot2.png-86a9db80ef4413e353c4339ad8f68a5f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/gdUnit4/src/update/assets/embedded.png b/addons/gdUnit4/src/update/assets/embedded.png
new file mode 100644
index 0000000..15cb9ab
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/embedded.png
Binary files differ
diff --git a/addons/gdUnit4/src/update/assets/embedded.png.import b/addons/gdUnit4/src/update/assets/embedded.png.import
new file mode 100644
index 0000000..a02de65
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/embedded.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bm8tdlw36fu7q"
+path="res://.godot/imported/embedded.png-29390948772209a603567d24f8766495.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/update/assets/embedded.png"
+dest_files=["res://.godot/imported/embedded.png-29390948772209a603567d24f8766495.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/addons/gdUnit4/src/update/assets/horizontal-line2.png b/addons/gdUnit4/src/update/assets/horizontal-line2.png
new file mode 100644
index 0000000..66aa098
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/horizontal-line2.png
Binary files differ
diff --git a/addons/gdUnit4/src/update/assets/horizontal-line2.png.import b/addons/gdUnit4/src/update/assets/horizontal-line2.png.import
new file mode 100644
index 0000000..3148524
--- /dev/null
+++ b/addons/gdUnit4/src/update/assets/horizontal-line2.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://w1d7ughjf1b3"
+path="res://.godot/imported/horizontal-line2.png-92618e6ee5cc9002847547a8c9deadbc.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gdUnit4/src/update/assets/horizontal-line2.png"
+dest_files=["res://.godot/imported/horizontal-line2.png-92618e6ee5cc9002847547a8c9deadbc.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1