diff --git a/sources/Core/Testing/IssueSummary.uc b/sources/Core/Testing/IssueSummary.uc new file mode 100644 index 0000000..27e20d7 --- /dev/null +++ b/sources/Core/Testing/IssueSummary.uc @@ -0,0 +1,294 @@ +/** + * Class for storing and processing the information about how well testing + * against a certain issue went. + * Copyright 2020 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class IssueSummary extends AcediaObject; + +// Each issue is uniquely identified by these values. +var private class ownerCase; +var private string context; +var private string description; + +// Records, in chronological order, results of the tests that were +// run to test this issue. +var private array successRecords; + +private final function byte BoolToByte(bool boolToConvert) +{ + if (boolToConvert) return 1; + return 0; +} + +/** + * Sets `TestCase`, context and description for the issue, + * tracked in this summary. + * + * Can only be successfully called once, but will fail if passed a `none` + * class reference to `TestCase`. + * + * @param targetCase `TestCase`, in which issue, + * relevant to this summary, is defined. + * @param targetContext Context, in which this issue, + * relevant to this summary, is defined. + * @param targetDescription Description of the issue relevant to + * this summary. + * @return `true` if `TestCase`, context and description were successfully set, + * `false` otherwise. + */ +public final function bool SetIssue( + class targetCase, + string targetContext, + string targetDescription +) +{ + if (ownerCase != none) return false; + if (initCase == none) return false; + ownerCase = targetCase; + context = targetContext; + description = targetDescription; + return true; +} + +/** + * Returns context for the issue in question. + * + * `TestCase` can be important for both displaying information about testing to + * the user and distinguishing between two different issues with the same + * description and context. + * @see `TestCase` for more information. + * + * @return Test case that tested for relevant issue. + */ +public final function class GetTestCase() +{ + return ownerCase; +} + +/** + * Returns context for the issue in question. + * + * Context can be important for both displaying information about testing to + * the user and distinguishing between two different issues with + * the same description and in the same `TestCase`. + * @see `TestCase` for more information. + * + * @return Context for relevant issue. + */ +public final function string GetContext() +{ + if (ownerCase == none) return ""; + return context; +} + +/** + * Returns description for the issue in question. + * + * Description of an issue is the main way to distinguish between + * different possibly arising problems. + * Two different issues can have the same description if they are defined + * in different `TestCase`s and/or in different context. + * @see `TestCase` for more information. + * + * @return Description for the issue in question. + */ +public final function string GetDescription() +{ + if (ownerCase == none) return ""; + return description; +} + +/** + * Adds result of another test (success or not) to the records of this summary. + * + * @param success `true` if test was successful and had passed, + * `false` otherwise. + */ +public final function AddTestResult(bool success) +{ + successRecords[successRecords.length] = BoolToByte(success); +} + +/** + * Returns total amount of test results recorded in caller summary. + * Never a negative value. + * + * @return Amount of tests that were run. + */ +public final function int GetTotalTestsAmount() +{ + return successRecords.length; +} + +/** + * Returns total amount of recorded successful test results in caller summary. + * Never a negative value. + * + * @return Amount of recorded successfully performed tests for + * the relevant issue. + */ +public final function int GetSuccessfulTestsAmount() +{ + local int i; + local int counter; + counter = 0; + for (i = 0; i < successRecords.length; i += 1) + { + if (successRecords[i] > 0) { + counter += 1; + } + } + return counter; +} + +/** + * Returns total amount of recorded failed test results in caller summary. + * Never a negative value. + * + * @return Amount of recorded failed tests for the relevant issue. + */ +public final function int GetFailedTestsAmount() +{ + return GetTotalTestsAmount() - GetSuccessfulTestsAmount(); +} + +/** + * Returns total success rate ("amount of successes" / "total amount of tests") + * of recorded test results for relevant issue + * (value between 0 and 1, including boundaries). + * + * If there are no test results recorded - returns `-1`. + * + * @return Success rate of recorded test results for the relevant issue + * Returns values outside [0; 1] segment (specifically, negative values) + * iff no test results at all were recorded. + */ +public final function float GetSuccessRate() +{ + local int totalTestsAmount; + totalTestsAmount = GetTotalTestsAmount(); + if (totalTestsAmount <= 0) { + return -1; + } + return GetSuccessfulTestsAmount() / totalTestsAmount; +} + +/** + * Checks whether all tests recorded in this summary have passed. + * + * @return `true` if all tests for relevant issue have passed, + * `false` otherwise. + */ +public final function bool HasPassedAllTests() +{ + return (GetFailedTestsAmount() <= 0); +} + +/** + * Returns boolean array of test results: each element recording whether test + * was a success (`>0`) or a failure (`0`). + * + * All results in the array are in a chronological order of arrival. + * + * @return Returns copy of boolean array of recorded test results. + */ +public final function array GetTestRecords() +{ + return successRecords; +} + +/** + * Returns index numbers (starting from 1, not 0) of tests that ended in + * a success, while performed for the same test case, context and issue. + * So if tests went: [success, success, failure, success, failure], + * method will return: [1, 2, 4]. + * + * All results in the array are in a chronological order of arrival. + * + * @return index numbers of successful tests. + */ +public final function array GetSuccessfulTests() +{ + local int i; + local array result; + for (i = 0; i < successRecords.length; i += 1) + { + if (successRecords[i] > 0) { + result[result.length] = i + 1; + } + } + return result; +} + +/** + * Returns index numbers (starting from 1, not 0) of tests that ended in + * a failure, while performed for the same test case, context and issue. + * So if tests went: [success, success, failure, success, failure], + * method will return: [3, 5]. + * + * All results in the array are in a chronological order of arrival. + * + * @return index numbers of successful tests. + */ +public final function array GetFailedTests() +{ + local int i; + local array result; + for (i = 0; i < successRecords.length; i += 1) + { + if (successRecords[i] == 0) { + result[result.length] = i + 1; + } + } + return result; +} + +/** + * Returns a formatted text representation of the caller `IssueSummary` + * in a following format: + * "{$text_default } {$text_subtle []}" + * + * @return Formatted string with text representation of the + * caller `IssueSummary`. + */ +public final function string ToString() +{ + local int i; + local string result; + local array failedTests; + result = "{$text_default" @ GetDescription() $ "}"; + if (GetFailedTestsAmount() <= 0) { + return result; + } + result @= "{$text_subtle ["; + failedTests = GetFailedTests(); + for (i = 0; i < failedTests.length; i += 1) + { + if (i < failedTests.length - 1) { + result $= string(failedTests[i]) $ ", "; + } + else { + result $= string(failedTests[i]); + } + } + return (result $ "]"); +} + +defaultproperties +{ +} \ No newline at end of file diff --git a/sources/Core/Testing/Service/TestingEvents.uc b/sources/Core/Testing/Service/TestingEvents.uc new file mode 100644 index 0000000..167f14b --- /dev/null +++ b/sources/Core/Testing/Service/TestingEvents.uc @@ -0,0 +1,66 @@ +/** + * Event generator for events related to testing. + * Copyright 2020 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class TestingEvents extends Events + abstract; + +static function CallTestingBegan(array< class > testQueue) +{ + local int i; + local array< class > listeners; + listeners = GetListeners(); + for (i = 0; i < listeners.length; i += 1) + { + class(listeners[i]) + .static.TestingBegan(testQueue); + } +} + +static function CallCaseTested( + class testedCase, + TestCaseSummary result) +{ + local int i; + local array< class > listeners; + listeners = GetListeners(); + for (i = 0; i < listeners.length; i += 1) + { + class(listeners[i]) + .static.CaseTested(testedCase, result); + } +} + +static function CallTestingEnded( + array< class > testQueue, + array results) +{ + local int i; + local array< class > listeners; + listeners = GetListeners(); + for (i = 0; i < listeners.length; i += 1) + { + class(listeners[i]) + .static.TestingEnded(testQueue, results); + } +} + +defaultproperties +{ + relatedListener = class'TestingListenerBase' +} \ No newline at end of file diff --git a/sources/Core/Testing/Service/TestingListenerBase.uc b/sources/Core/Testing/Service/TestingListenerBase.uc new file mode 100644 index 0000000..92272bb --- /dev/null +++ b/sources/Core/Testing/Service/TestingListenerBase.uc @@ -0,0 +1,34 @@ +/** + * Listener for events related to testing. + * Copyright 2020 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class TestingListenerBase extends Listener + abstract; + +static function TestingBegan(array< class > testQueue) {} + +static function CaseTested(class testQueue, TestCaseSummary result) {} + +static function TestingEnded( + array< class > testedCase, + array results) {} + +defaultproperties +{ + relatedEvents = class'TestingEvents' +} \ No newline at end of file diff --git a/sources/Core/Testing/Service/TestingService.uc b/sources/Core/Testing/Service/TestingService.uc new file mode 100644 index 0000000..896a932 --- /dev/null +++ b/sources/Core/Testing/Service/TestingService.uc @@ -0,0 +1,253 @@ +/** + * This service allows to separate running separate `TestCase`s in separate + * ticks, which helps to avoid hang ups or false infinite loop detection. + * Copyright 2020 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class TestingService extends Service + config(AcediaSystem); + +// All test cases, loaded from all available packages. +// Always use `default` copy of this array. +var private array< class > registeredTestCases; + +// Will be `true` if we have yet more tests to run +// (either during current or following ticks) +var private bool runningTests; +// Queue with all test cases for the current/next testing +var private array< class > testCasesToRun; +// Track which test case we need to execute during next tick +var private int nextTestCase; + +// Record test results during the last test run here. +// After testing has finished - copy them into it's default value +// `default.summarizedResults` to be available even after `TestingService` +// shuts down. +var private array summarizedResults; + +// Configuration variables that tell Acedia what tests to run +// (and whether to run any at all) on start up. +var public config const bool runTestsOnStartUp; +var public config const bool filterTestsByName; +var public config const bool filterTestsByGroup; +var public config const string requiredName; +var public config const string requiredGroup; + +// Shortcut to `TestingEvents`, so that we don't have to write +// class'TestingEvents' every time. +var const class events; + +/** + * Registers another `TestCase` class for later testing. + * + * @return `true` if registration was successful. + */ +public final static function bool RegisterTestCase(class newTestCase) +{ + local int i; + if (newTestCase == none) return false; + + for (i = 0; i < default.registeredTestCases.length; i += 1) + { + if (default.registeredTestCases[i] == newTestCase) { + return false; + } + // Warn if there are test cases with the same name and group + if ( !(default.registeredTestCases[i].static.GetGroup() + ~= newTestCase.static.GetGroup())) { + continue; + } + if ( !(default.registeredTestCases[i].static.GetName() + ~= newTestCase.static.GetName())) { + continue; + } + default._.logger.Warning("Two different test cases with name \"" + $ newTestCase.static.GetName() $ "\" in the same group \"" + $ newTestCase.static.GetGroup() $ "\"have been registered:" + @ "\"" $ string(newTestCase) $ "\" and \"" + $ string(default.registeredTestCases[i]) + $ "\". This can lead to issues and it is not something you can fix," + @ "- contact developers of the relevant packages."); + } + default.registeredTestCases[default.registeredTestCases.length] = + newTestCase; + return true; +} + +/** + * Checks whether service is still in the process of running tests. + * + * @return `true` if there are still some tests that are scheduled, but + * were not yet ran and `false` otherwise. + */ +public final static function bool IsRunningTests() +{ + local TestingService myInstance; + myInstance = TestingService(class'TestingService'.static.GetInstance()); + if (myInstance == none) return false; + + return myInstance.runningTests; +} + +/** + * Returns the results of the last tests run. + * + * If no tests were run - returns an empty array. + * + * @return Results of the last tests run. + */ +public final static function array GetLastResults() +{ + return default.summarizedResults; +} + +/** + * Adds all tests to the testing queue. + * + * To actually run them use `Run()`. + * To only run certain tests, - filter them by `FilterByName()` + * and `FilterByGroup()` + * + * Will do nothing if service is already in the process of testing + * (`IsRunningTests() == true`). + * + * @return Caller `TestService` to allow for method chaining. + */ +public final function TestingService PrepareTests() +{ + if (runningTests) { + return self; + } + testCasesToRun = default.registeredTestCases; + return self; +} + +/** + * Filters tests in current queue to only those that have a specific name. + * Should be used after `PrepareTests()` call, but before `Run()`. + * + * Will do nothing if service is already in the process of testing + * (`IsRunningTests() == true`). + * + * @return Caller `TestService` to allow for method chaining. + */ +public final function TestingService FilterByName(string caseName) +{ + local int i; + local array< class > preFiltered; + if (runningTests) { + return self; + } + preFiltered = testCasesToRun; + testCasesToRun.length = 0; + for (i = 0; i < preFiltered.length; i += 1) + { + if (preFiltered[i].static.GetName() ~= caseName) { + testCasesToRun[testCasesToRun.length] = preFiltered[i]; + } + } + return self; +} + +/** + * Filters tests in current queue to only those that belong to + * a specific group. Should be used after `PrepareTests()` call, + * but before `Run()`. + * + * Will do nothing if service is already in the process of testing + * (`IsRunningTests() == true`). + * + * @return Caller `TestService` to allow for method chaining. + */ +public final function TestingService FilterByGroup(string caseGroup) +{ + local int i; + local array< class > preFiltered; + if (runningTests) { + return self; + } + preFiltered = testCasesToRun; + testCasesToRun.length = 0; + for (i = 0; i < preFiltered.length; i += 1) + { + if (preFiltered[i].static.GetGroup() ~= caseGroup) { + testCasesToRun[testCasesToRun.length] = preFiltered[i]; + } + } + return self; +} + +/** + * Makes `TestingService` run all tests in a current queue. + * + * Queue musty be build before hand: start with `PrepareTests()` call and + * optionally use `FilterByName()` / `FilterByGroup()` before + * `Run()` method call. + * + * @return `false` if service is already performing the testing + * and `true` otherwise. Note that `TestingService` might be inactive even + * after `Run()` call that returns `true`, if the testing queue was empty. + */ +public final function bool Run() +{ + if (runningTests) { + return false; + } + nextTestCase = 0; + runningTests = true; + summarizedResults.length = 0; + events.static.CallTestingBegan(testCasesToRun); + if (testCasesToRun.length <= 0) { + runningTests = false; + events.static.CallTestingEnded(testCasesToRun, summarizedResults); + } + return true; +} + +private final function DoTestingStep() +{ + local TestCaseSummary newResult; + if (nextTestCase >= testCasesToRun.length) + { + runningTests = false; + default.summarizedResults = summarizedResults; + events.static.CallTestingEnded(testCasesToRun, summarizedResults); + return; + } + testCasesToRun[nextTestCase].static.PerformTests(); + newResult = testCasesToRun[nextTestCase].static.GetSummary(); + events.static.CallCaseTested(testCasesToRun[nextTestCase], newResult); + summarizedResults[summarizedResults.length] = newResult; + nextTestCase += 1; +} + +event Tick(float delta) +{ + // This will destroy us on the next tick after we were + // either created or finished performing tests + if (!runningTests) { + Destroy(); + return; + } + DoTestingStep(); +} + +defaultproperties +{ + runTestsOnStartUp = false + events = class'TestingEvents' +} \ No newline at end of file diff --git a/sources/Core/Testing/TestCase.uc b/sources/Core/Testing/TestCase.uc index 43318a6..7f69117 100644 --- a/sources/Core/Testing/TestCase.uc +++ b/sources/Core/Testing/TestCase.uc @@ -1,13 +1,8 @@ /** - * Base class aimed to contain sets of unit tests for various components of + * Base class aimed to contain sets of tests for various components of * Acedia and it's features. - * Currently provides bare-bones testing functions that check boolean - * variables for true/false and objects for whether they're `none` or not. - * Tests: - * ~ can be grouped by their "context", - * describing what they are testing; - * ~ test (or several tests) can be assigned an error message, - * describing what exactly went wrong. + * Neither this class, nor it's children aren't supposed to + * be instantiated. * Copyright 2020 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. @@ -30,202 +25,165 @@ class TestCase extends AcediaObject // Name by which this set of unit tests can be referred to. var protected const string caseName; +// Name of group to which this set of unit tests belong. +var protected const string caseGroup; -// Information about how well testing went for a particular context, -// i.e. subsets of tests for a particular functionality. -struct ContextSummary -{ - // Text, human-readable description of the purpose of - // tests in this context. - var string description; - // `false` if at least one test failed, `true` otherwise. - var bool passed; - // How many test were performed. - var int testsPerformed; - // How many tests failed. - var int testsFailed; - // Error messages generated by failed tests. - var array errors; -}; - -// Collection of summaries for all contexts defined by the user so far. -struct Summary -{ - var bool passed; - var array contextSummaries; -}; - -// Has function for defining context (`Context()`) been called. -var private bool userDefinedContext; // Were all tests performed? -var private bool finishedTests; +var private bool finishedTests; +// Context under which we are currently performing our tests. +var private string currentContext; // Error message that will be generated if some test will fail now. -var private string currentErrorMessage; +var private string currentIssue; -// Store complete summary here. -var private Summary currentSummary; -// For quick access store current context's summary here and update it in -// `currentSummary` once done. -var private ContextSummary activeContextSummary; +// Summary where we are recording results of all our tests. +var private TestCaseSummary currentSummary; -// Call this function to define a context for subsequent test -// (until another call). -public final static function Context(string description) +/** + * Sets context for any tests that will follow this call (but before the next + * `Context()` call). + * + * Context is supposed to be a short description about what + * exactly you are testing. When reporting failed tests, - failures will be + * grouped up by a context. + * + * Changing current context will also reset current issue, to set it up + * use `Issue()` method. + * + * @param context Context for the following tests. + */ +public final static function Context(string context) { - if ( default.userDefinedContext - || default.activeContextSummary.testsPerformed > 0) - { - UpdateContextSummary(default.activeContextSummary); - } - default.userDefinedContext = true; - default.activeContextSummary = GetContextSummary(description); - default.currentErrorMessage = ""; + default.currentContext = context; + default.currentIssue = ""; // Reset issue. } // Call this function to define an error message for tests that // would fail after it. // Message is reset by another call of `Issue()` or // by changing the context via `Context()`. -public final static function Issue(string errorMessage) +/** + * Changes an issue that any following tests (but before the next `Issue()` or + * `Context()` call) will test for. + * + * Issue is the message that will be displayed to the user if any relevant + * tests have failed. + * + * NOTE: Current issue will be reset by any `Context()` call. + * + * @param issue Issue that following tests will test for. + */ +public final static function Issue(string issue) { - default.currentErrorMessage = errorMessage; + default.currentIssue = issue; } -// All tests to be performed can be placed in this function, -// along with appropriate calls to `Context()` and `Issue()`. -// For an example see class `TEST_JSON`. -protected static function TESTS(){} +// Following functions provide simple test primitives -// Following functions provide simple test primitives, +/** + * This call will record either one success or one failure for the caller + * `TestCase` class, depending on passed `bool` argument. + * + * @param result Your test's result as a `bool` value: `true` will record a + * success and `false` a failure. + */ public final static function TEST_ExpectTrue(bool result) { - RecordTestResult(result, default.currentErrorMessage); + RecordTestResult(result); } +/** + * This call will record either one success or one failure for the caller + * `TestCase` class, depending on passed `bool` argument. + * + * @param result Your test's result as a `bool` value: `false` will result in + * recording a success and `true` in a failure. + */ public final static function TEST_ExpectFalse(bool result) { - RecordTestResult(!result, default.currentErrorMessage); + RecordTestResult(!result); } +/** + * This call will record either one success or one failure for the caller + * `TestCase` class, depending on passed `Object` argument. + * + * @param result Your test's result as an `Object` value: `none` will result + * in recording success and any non-`none` value in failure. + */ public final static function TEST_ExpectNone(Object object) { - RecordTestResult(object == none, default.currentErrorMessage); + RecordTestResult(object == none); } +/** + * This call will record either one success or one failure for the caller + * `TestCase` class, depending on passed `Object` argument. + * + * @param result Your test's result as an `Object` value: any non-`none` + * value will result in recording success and `none` in failure. + */ public final static function TEST_ExpectNotNone(Object object) { - RecordTestResult(object != none, default.currentErrorMessage); -} - -// Returns the summary of how testing went. -public final static function Summary GetSummary() -{ - return default.currentSummary; + RecordTestResult(object != none); } -// Name by which this set of unit tests can be referred to. -public final static function string GetName() -{ - return default.caseName; -} - -// Creates brand new summary for context with a given description, -// marked as "passed" and zero tests done. -private final static function ContextSummary NewContextSummary -( - string description -) +// Records (in current context summary) that another test was performed and +// succeeded/failed, along with given error message. +private final static function RecordTestResult(bool isSuccessful) { - local ContextSummary newSummary; - newSummary.passed = true; - newSummary.description = description; - newSummary.testsPerformed = 0; - newSummary.testsFailed = 0; - newSummary.errors.length = 0; - return newSummary; + if (default.finishedTests) return; + if (default.currentSummary == none) return; + default.currentSummary.AddTestResult( default.currentContext, + default.currentIssue, + isSuccessful); } -// Returns index of summary with given description -// in our records (`currentSummary`). -// Return `-1` if there is no context with such description. -private final static function int GetContextSummaryIndex(string description) +/** + * Once testing has finished returns compiled results as a + * `TestCaseSummary` object. + * + * @return `TestCaseSummary` with compiled results if the testing has finished + * and `none` otherwise. + */ +public final static function TestCaseSummary GetSummary() { - local int i; - for (i = 0; i < default.currentSummary.contextSummaries.length; i += 1) - { - if ( default.currentSummary.contextSummaries[i].description - ~= description) - { - return i; - } + if (!default.finishedTests) { + return none; } - return -1; + return default.currentSummary; } -// Returns index summary with given description -// in our records. -// Return new context summary if there is no context with such description. -private final static function ContextSummary GetContextSummary -( - string description -) -{ - local int index; - if (default.activeContextSummary.description ~= description) - { - return default.activeContextSummary; - } - index = GetContextSummaryIndex(description); - if (index < 0) - { - return NewContextSummary(description); - } - return default.currentSummary.contextSummaries[index]; +/** + * Checks whether this `TestCase` has already finished running all it's tests. + * Finished testing means a prepared `TestCaseSummary` is available + * (by `GetSummary()` method). + * + * @return `true` if this test case already did the testing + * and `false` otherwise. + */ +public final static function bool HasFinishedTesting() +{ + return default.finishedTests; } -// Rewrites summary (with the same name as a given summary) -// in `currentSummary` records. -// If there's no such record - adds a new one. -private final static function UpdateContextSummary -( - ContextSummary relevantSummary -) +/** + * Returns name of this `TestCase`. + * + * @return Name of this `TestCase`. + */ +public final static function string GetName() { - local int index; - index = GetContextSummaryIndex(relevantSummary.description); - if (index < 0) - { - index = default.currentSummary.contextSummaries.length; - } - default.currentSummary.contextSummaries[index] = relevantSummary; + return default.caseName; } -// Records (in current context summary) that another test was performed and -// succeeded/failed, along with given error message. -private final static function RecordTestResult -( - bool isSuccessful, - string errorMessage -) +/** + * Returns group name of this `TestCase`. + * + * @return Group name of this `TestCase`. + */ +public final static function string GetGroup() { - local int i; - local int errorsAmount; - if (default.finishedTests) return; - default.activeContextSummary.testsPerformed += 1; - if (isSuccessful) return; - - default.currentSummary.passed = false; - default.activeContextSummary.passed = false; - default.activeContextSummary.testsFailed += 1; - errorsAmount = default.activeContextSummary.errors.length; - for (i = 0; i < errorsAmount; i += 1) - { - if (default.activeContextSummary.errors[i] ~= errorMessage) - { - return; - } - } - default.activeContextSummary.errors[errorsAmount] = errorMessage; + return default.caseGroup; } // Calling this function will perform unit tests defined in `TESTS()` @@ -233,31 +191,36 @@ private final static function RecordTestResult // obtainable through `GetSummary()` function. // Returns `true` if all tests have successfully passed // and `false` otherwise. +/** + * Performs all tests for this `TestCase`. + * Guaranteed to be done after this finishes. + * + * @return `true` if all tests have finished successfully + * and `false` otherwise. + */ public final static function bool PerformTests() { - default.finishedTests = false; - default.userDefinedContext = false; - default.currentSummary.passed = true; - default.currentSummary.contextSummaries.length = 0; - default.activeContextSummary = NewContextSummary(""); + default.finishedTests = false; + _().memory.Free(default.currentSummary); + default.currentSummary = new class'TestCaseSummary'; + default.currentSummary.Initialize(default.class); TESTS(); - UpdateContextSummary(default.activeContextSummary); default.finishedTests = true; - return default.currentSummary.passed; + return default.currentSummary.HasPassedAllTests(); } -// TODO: Support for testing in stages to avoid infinite loop crashes. -// TODO: Add support for test scening: grabbing pawns, placing them, waiting. -// TODO: Expand scening support: triggering functions on client, moving. -// TODO: Expand scening support: zed spawning, aggro setting. -// TODO: Expand scening support: function calls (i.e. for CashToss), -// testing `FixDoshSpam` feature. -// TODO: Expand scening support: lag detection. -// TODO: Expand scening support: test `FixZedTime`. -// TODO: Expand scening support: aiming shooting, detecting damage. -// TODO: Expand scening support: testing `FixFFHack`. -// TODO: Testing infinite nade (partially), ammo selling, dualies cost. +/** + * Any tests that your `TestCase` class needs to perform should be put in + * this function. + * To separate tests into groups it's recommended (as a style + * consideration) to put them in separate function calls and give these + * functions names starting with "Test_". They can have further folded + * functions with prefix "SubTest_", which can contain "SubSubTest_", etc.. + */ +protected static function TESTS(){} + defaultproperties { caseName = "" + caseGroup = "" } \ No newline at end of file diff --git a/sources/Core/Testing/TestCaseSummary.uc b/sources/Core/Testing/TestCaseSummary.uc new file mode 100644 index 0000000..b6b8719 --- /dev/null +++ b/sources/Core/Testing/TestCaseSummary.uc @@ -0,0 +1,540 @@ +/** + * Class for storing and processing the information about how well testing + * for a certain `TestCase` went. That information is stored as + * a collection of `IssueSummary`s, that can be accessed all at once + * or by their context. + * `TestCaseSummary` must be initialized for some `TestCase` before it can + * be used for anything (unlike `IssueSummary`). + * Copyright 2020 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class TestCaseSummary extends AcediaObject; + +// Case for which this summary was initialized. +// `none` if it was not. +var private class ownerCase; + +/** + * + * We will store issue summaries for different contexts separately. + * INVARIANT: any function that adds records to `contextRecords` + * must guarantee that: + * 1. No two distinct records will have the same `context`; + * 2. All the `IssueSummary`s in `issueSummaries` array have different + * issue descriptions. + * Comparisons of `string`s for two above conditions are case-insensitive. + */ +struct ContextRecord +{ + var string context; + var array issueSummaries; +}; +var private array contextRecords; + +// String literals used for displaying array of test case summaries +var private const string indent; +var private const string reportHeader; +var private const string reportSuccessfulEnding; +var private const string reportUnsuccessfulEnding; + +/** + * Initializes caller summary for given `TestCase` class. + * Can only be successfully done once, but will fail if + * passed a `none` reference. + * + * @param targetCase `TestCase` class for which this summary will be + * recording test results. + * @return `true` if initialization was successful and `false otherwise + * (either summary already initialized or passed reference is `none`). + */ +public final function bool Initialize(class targetCase) +{ + if (ownerCase != none) return false; + if (targetCase == none) return false; + ownerCase = targetCase; + return true; +} + +/** + * Returns index of a context record with a given description + * (`context`) in `contextRecords`. + * Creates one if missing. Never fails. + * + * @param context Context that desired record must match. + * @return Index of the context record that matches `context`. + * Returned index is always valid. + */ +private final function int TouchContext(string context) +{ + local int i; + local ContextRecord newRecord; + // Try to find existing record with given context description + for (i = 0; i < contextRecords.length; i += 1) + { + if (context ~= contextRecords[i].context) { + return i; + } + } + // If there is none - make a new one + newRecord.context = context; + contextRecords[contextRecords.length] = newRecord; + return (contextRecords.length - 1); +} + +/** + * Finds indices of a context record and an `IssueSummary` in + * a nested array that have matching `context` + * and `issueDescription`. + * Creates records and/or `IssueSummary` if missing. Never fails. + * + * @param context Context description that + * desired record must match. + * @param issueDescription Issue description that + * desired `IssueSummary`must match. + * @param recordIndex Index of the context record that matches + * `context` description will be recorded here. + * Returned value is always valid. Passed value is discarded. + * @param recordIndex Index of the `IssueSummary` that matches + * `issueDescription` description will be recorded here. + * Returned value is always valid. Passed value is discarded. + */ +private final function TouchIssue( + string context, + string issueDescription, + out int recordIndex, + out int issueIndex +) +{ + local int i; + local array issueSummaries; + recordIndex = TouchContext(context); + issueSummaries = contextRecords[recordIndex].issueSummaries; + // Try to find existing issue summary with a given description + for (i = 0; i < issueSummaries.length; i += 1) + { + if (issueSummaries[i] == none) continue; + if (issueDescription ~= issueSummaries[i].GetDescription()) + { + issueIndex = i; + return; + } + } + // If there is none - add a new one + issueIndex = issueSummaries.length; + issueSummaries[issueIndex] = new class'IssueSummary'; + issueSummaries[issueIndex].SetIssue(ownerCase, context, issueDescription); + contextRecords[recordIndex].issueSummaries = issueSummaries; +} + +/** + * Checks if caller summary was correctly initialized. + * + * @return `true` if summary was correctly initialized and `false` otherwise. + */ +public final function bool IsInitialized() +{ + return (ownerCase != none); +} + +/** + * Adds result of another test (success or not) to the records of this summary. + * + * @param context Context under which test was performed. + * @param issueDescription Description of issue, + * for which test was performed. + * @param success `true` if test was successful and had passed, + * `false` otherwise. + */ +public final function AddTestResult( + string context, + string issueDescription, + bool success +) +{ + local int recordIndex, issueIndex; + TouchIssue(context, issueDescription, recordIndex, issueIndex); + contextRecords[recordIndex] + .issueSummaries[issueIndex] + .AddTestResult(success); +} + +/** + * Returns all contexts, for which caller summary has any records of tests + * being performed. + * + * To check if particular context exists you can use `DoesContextExists()`. + * + * @return Array of `string`s, each representing one of the contexts, + * used in tests. + * Guarantees no duplicates (equality without accounting for case). + */ +public final function array GetContexts() +{ + local int i; + local array result; + for (i = 0; i < contextRecords.length; i += 1) { + result[result.length] = contextRecords[i].context; + } + return result; +} + +/** + * Checks if given context has any records about performing tests + * (whether they ended in success or a failure) under it. + * + * To get an array of all existing contexts use `GetContexts()`. + * + * @param context A context to check for existing in records. + * @return `true` if there was a record about a test being performed under + * a given context and `false` otherwise. + */ +public final function bool DoesContextExists(string context) +{ + local int i; + for (i = 0; i < contextRecords.length; i += 1) + { + if (contextRecords[i].context ~= context) { + return true; + } + } + return false; +} + +/** + * `IssueSummary`s for every issue that was tested and recorded in + * the caller `TestCaseSummary`. + * + * @return Array of `IssueSummary`s for every tested and recorded issue. + */ +public final function array GetIssueSummaries() +{ + local int i, j; + local array recordedSummaries; + local array result; + for (i = 0; i < contextRecords.length; i += 1) + { + recordedSummaries = contextRecords[i].issueSummaries; + for (j = 0; j < recordedSummaries.length; j += 1) { + result[result.length] = recordedSummaries[j]; + } + } + return result; +} + +/** + * Returns `IssueSummary`s for every issue that was tested under + * a given context and recorded in caller `TestCaseSummary`. + * + * @param context Context under which issues of interest were tested. + * @return Array of `IssueSummary`s for every issue that was tested under + * given context. + */ +public final function array GetIssueSummariesForContext( + string context +) +{ + local int i; + local array emptyResult; + for (i = 0; i < contextRecords.length; i += 1) + { + if (contextRecords[i].context ~= context) { + return contextRecords[i].issueSummaries; + } + } + return emptyResult; +} + +// Counts total amount of tests performed under the contexts +// corresponding to `contextRecords[recordIndex]` record. +private final function int GetTotalTestsAmountForRecord(int recordIndex) +{ + local int i; + local int result; + local array issueSummaries; + issueSummaries = contextRecords[recordIndex].issueSummaries; + result = 0; + for (i = 0; i < issueSummaries.length; i += 1) + { + if (issueSummaries[i] == none) continue; + result += issueSummaries[i].GetTotalTestsAmount(); + } + return result; +} + +/** + * Total amount of performed tests, recorded in caller `TestCaseSummary`. + * + * If you are interested in amount of test under a specific context, - + * use `GetTotalTestsAmountForContext()` instead. + * + * @return Total amount of performed tests. + */ +public final function int GetTotalTestsAmount() +{ + local int i; + local int result; + for (i = 0; i < contextRecords.length; i += 1) + { + result += GetTotalTestsAmountForRecord(i); + } + return result; +} + +/** + * Total amount of tests, performed under a context `context` and + * recorded in caller `TestCaseSummary`. + * + * If you are interested in total amount of test under all contexts, - + * use `GetTotalTestsAmount()` instead. + * + * @param context Context for which method must count amount of + * performed tests. + * @return Total amount of tests, performed under given context. + * If given context does not exist in records, - returns `-1`. + */ +public final function int GetTotalTestsAmountForContext(string context) +{ + local int i; + for (i = 0; i < contextRecords.length; i += 1) + { + if (context ~= contextRecords[i].context) { + return GetTotalTestsAmountForRecord(i); + } + } + return -1; +} + +// Counts total amount of successful tests performed under the contexts +// corresponding to `contextRecords[recordIndex]` record. +private final function int GetSuccessfulTestsAmountForRecord(int recordIndex) +{ + local int i; + local int result; + local array issueSummaries; + issueSummaries = contextRecords[recordIndex].issueSummaries; + result = 0; + for (i = 0; i < issueSummaries.length; i += 1) + { + if (issueSummaries[i] == none) continue; + result += issueSummaries[i].GetSuccessfulTestsAmount(); + } + return result; +} + +/** + * Total amount of successfully performed tests, + * recorded in caller `TestCaseSummary`. + * + * If you are interested in amount of successful test under a specific context, + * - use `GetSuccessfulTestsAmountForContext()` instead. + * + * @return Total amount of successfully performed tests. + */ +public final function int GetSuccessfulTestsAmount() +{ + local int i; + local int result; + for (i = 0; i < contextRecords.length; i += 1) + { + result += GetSuccessfulTestsAmountForRecord(i); + } + return result; +} + +/** + * Total amount of tests, performed under a context `context` and + * recorded in caller `TestCaseSummary`. + * + * If you are interested in total amount of successful test under all contexts, + * - use `GetSuccessfulTestsAmount()` instead. + * + * @param context Context for which we method must count amount of + * successful tests. + * @return Total amount of successful tests, performed under given context. + * If given context does not exist in records, - returns `-1`. + */ +public final function int GetSuccessfulTestsAmountForContext(string context) +{ + local int i; + for (i = 0; i < contextRecords.length; i += 1) + { + if (context ~= contextRecords[i].context) { + return GetSuccessfulTestsAmountForRecord(i); + } + } + return -1; +} + +// Counts total amount of tests, failed under the contexts +// corresponding to `contextRecords[recordIndex]` record. +private final function int GetFailedTestsAmountForRecord(int recordIndex) +{ + local int i; + local int result; + local array issueSummaries; + issueSummaries = contextRecords[recordIndex].issueSummaries; + result = 0; + for (i = 0; i < issueSummaries.length; i += 1) + { + if (issueSummaries[i] == none) continue; + result += issueSummaries[i].GetFailedTestsAmount(); + } + return result; +} + +/** + * Total amount of failed tests, recorded in caller `TestCaseSummary`. + * + * If you are interested in amount of failed test under a specific context, - + * use `GetFailedTestsAmountForContext()` instead. + * + * @return Total amount of failed tests. + */ +public final function int GetFailedTestsAmount() +{ + local int i; + local int result; + for (i = 0; i < contextRecords.length; i += 1) + { + result += GetFailedTestsAmountForRecord(i); + } + return result; +} + +/** + * Total amount of failed tests, performed under a context `context` and + * recorded in caller `TestCaseSummary`. + * + * If you are interested in total amount of failed test under all contexts, - + * use `GetFailedTestsAmount()` instead. + * + * @param context Context for which method must count amount of + * failed tests. + * @return Total amount of failed tests, performed under given context. + * If given context does not exist in records, - returns `-1`. + */ +public final function int GetFailedTestsAmountForContext(string context) +{ + local int i; + for (i = 0; i < contextRecords.length; i += 1) + { + if (context ~= contextRecords[i].context) { + return GetFailedTestsAmountForRecord(i); + } + } + return -1; +} + +/** + * Checks whether all tests recorded in this summary have passed. + * + * @return `true` if all tests have passed, `false` otherwise. + */ +public final function bool HasPassedAllTests() +{ + return (GetFailedTestsAmount() <= 0); +} + +/** + * Checks whether all tests, performed under given context and + * recorded in this summary, have passed. + * + * @return `true` if all tests under given context have passed, + * `false` otherwise. + * If given context does not exists - it did not fail any tests. + */ +public final function bool HasPassedAllTestsForContext(string context) +{ + return (GetFailedTestsAmountForContext(context) <= 0); +} + +/** + * Generates a text summary for a set of results, given as array of + * `TestCaseSummary`s (exactly how results are returned by `TestingService`). + * + * @param summaries `TestCase` summaries (obtained as a result of testing) + * that we want to display. + * @return Test representation of `summaries` as an array of + * formatted strings, where each string corresponds to it's own line. + */ +public final static function array GenerateStringSummary( + array summaries) +{ + local int i; + local bool allTestsPassed; + local array result; + allTestsPassed = true; + result[0] = default.reportHeader; + for (i = 0; i < summaries.length; i += 1) + { + if (summaries[i] == none) continue; + summaries[i].AppendCaseSummary(result); + allTestsPassed = allTestsPassed && summaries[i].HasPassedAllTests(); + } + if (allTestsPassed) { + result[result.length] = default.reportSuccessfulEnding; + } + else { + result[result.length] = default.reportUnsuccessfulEnding; + } + return result; +} + +// Add text representation of caller `TestCase` to the existing array `result`. +private final function AppendCaseSummary(out array result) +{ + local int i, j; + local array contexts; + local string testCaseAnnouncement; + local array issues; + if (ownerCase == none) return; + // Announce case + testCaseAnnouncement = "{$text_default Test case {$text_emphasis"; + if (ownerCase.static.GetGroup() != "") { + testCaseAnnouncement @= "[" $ ownerCase.static.GetGroup() $ "]"; + } + testCaseAnnouncement @= ownerCase.static.GetName() $ "}:}"; + if (GetFailedTestsAmount() > 0) { + testCaseAnnouncement @= "{$text_failure failed}!"; + } + else { + testCaseAnnouncement @= "{$text_ok passed}!"; + } + result[result.length] = testCaseAnnouncement; + // Report failed tests + contexts = GetContexts(); + for (i = 0;i < contexts.length; i += 1) + { + if (GetFailedTestsAmountForContext(contexts[i]) <= 0) continue; + result[result.length] = "{$text_warning " $ contexts[i] $ "}"; + issues = GetIssueSummariesForContext(contexts[i]); + for (j = 0; j < issues.length; j += 1) + { + if (issues[j] == none) continue; + if (issues[j].GetFailedTestsAmount() <= 0) continue; + result[result.length] = indent $ issues[j].ToString(); + } + } +} + +defaultproperties +{ + indent = " " + reportHeader = "{$text_default ############################## {$text_emphasis Test summary} ###############################}" + reportSuccessfulEnding = "{$text_default ########################### {$text_ok All tests have passed!} ############################}" + reportUnsuccessfulEnding = "{$text_default ########################## {$text_failure Some tests have failed :(} ###########################}" +} \ No newline at end of file