diff --git a/docs/source/Coding/Unit-Testing.md b/docs/source/Coding/Unit-Testing.md index 3204effc9c..429caf791b 100644 --- a/docs/source/Coding/Unit-Testing.md +++ b/docs/source/Coding/Unit-Testing.md @@ -1,6 +1,5 @@ # Unit Testing - *Unit testing* means testing components of a program in isolation from each other to make sure every part works on its own before using it with others. Extensive testing helps avoid new updates causing unexpected side effects as well as alleviates general code rot (a more comprehensive wikipedia @@ -31,9 +30,9 @@ how many tests were run and how long it took. If something went wrong you will g If you contribute to Evennia, this is a useful sanity check to see you haven't introduced an unexpected bug. -## Running tests with custom settings file +## Running tests for your game dir -If you have implemented your own tests for your game (see below) you can run them from your game dir +If you have implemented your own tests for your game you can run them from your game dir with evennia test . @@ -41,8 +40,8 @@ with The period (`.`) means to run all tests found in the current directory and all subdirectories. You could also specify, say, `typeclasses` or `world` if you wanted to just run tests in those subdirs. -Those tests will all be run using the default settings. To run the tests with your own settings file -you must use the `--settings` option: +An important thing to note is that those tests will all be run using the _default Evennia settings_. +To run the tests with your own settings file you must use the `--settings` option: evennia test --settings settings.py . @@ -50,108 +49,184 @@ The `--settings` option of Evennia takes a file name in the `mygame/server/conf` normally used to swap settings files for testing and development. In combination with `test`, it forces Evennia to use this settings file over the default one. +You can also test specific things by giving their path + + evennia test --settings settings.py .world.tests.YourTest + + ## Writing new tests Evennia's test suite makes use of Django unit test system, which in turn relies on Python's *unittest* module. -> If you want to help out writing unittests for Evennia, take a look at Evennia's [coveralls.io -page](https://coveralls.io/github/evennia/evennia). There you see which modules have any form of -test coverage and which does not. - To make the test runner find the tests, they must be put in a module named `test*.py` (so `test.py`, `tests.py` etc). Such a test module will be found wherever it is in the package. It can be a good idea to look at some of Evennia's `tests.py` modules to see how they look. -Inside a testing file, a `unittest.TestCase` class is used to test a single aspect or component in -various ways. Each test case contains one or more *test methods* - these define the actual tests to -run. You can name the test methods anything you want as long as the name starts with "`test_`". -Your `TestCase` class can also have a method `setUp()`. This is run before each test, setting up and -storing whatever preparations the test methods need. Conversely, a `tearDown()` method can -optionally do cleanup after each test. +Inside the module you need to put a class inheriting (at any distance) from `unittest.TestCase`. Each +method on that class that starts with `test_` will be run separately as a unit test. There +are two special, optional methods `setUp` and `tearDown` that will (if you define them) run before +_every_ test. This can be useful for setting up and deleting things. -To test the results, you use special methods of the `TestCase` class. Many of those start with -"`assert`", such as `assertEqual` or `assertTrue`. +To actually test things, you use special `assert...` methods on the class. Most common on is +`assertEqual`, which makes sure a result is what you expect it to be. -Example of a `TestCase` class: +Here's an example of the principle. Let's assume you put this in `mygame/world/tests.py` +and want to test a function in `mygame/world/myfunctions.py` ```python + # in a module tests.py somewhere i your game dir import unittest + from evennia import create_object # the function we want to test - from mypath import myfunc + from .myfunctions import myfunc + class TestObj(unittest.TestCase): "This tests a function myfunc." + def setUp(self): + """done before every of the test_ * methods below""" + self.obj = create_object("mytestobject") + + def tearDown(self): + """done after every test_* method below """ + self.obj.delete() + def test_return_value(self): - "test method. Makes sure return value is as expected." - expected_return = "This is me being nice." - actual_return = myfunc() + """test method. Makes sure return value is as expected.""" + actual_return = myfunc(self.obj) + expected_return = "This is the good object 'mytestobject'." # test self.assertEqual(expected_return, actual_return) def test_alternative_call(self): - "test method. Calls with a keyword argument." - expected_return = "This is me being baaaad." - actual_return = myfunc(bad=True) + """test method. Calls with a keyword argument.""" + actual_return = myfunc(self.obj, bad=True) + expected_return = "This is the baaad object 'mytestobject'." # test self.assertEqual(expected_return, actual_return) ``` -You might also want to read the [documentation for the unittest -module](https://docs.python.org/library/unittest.html). +To test this, run -### Using the EvenniaTest class + evennia test --settings settings.py . -Evennia offers a custom TestCase, the `evennia.utils.test_resources.EvenniaTest` class. This class -initiates a range of useful properties on themselves for testing Evennia systems. Examples are -`.account` and `.session` representing a mock connected Account and its Session and `.char1` and -`char2` representing Characters complete with a location in the test database. These are all useful -when testing Evennia system requiring any of the default Evennia typeclasses as inputs. See the full -definition of the `EvenniaTest` class in -[evennia/utils/test_resources.py](https://github.com/evennia/evennia/blob/master/evennia/utils/test_resources.py). +to run the entire test module + + evennia test --settings setings.py .world.tests + +or a specific class: + + evennia test --settings settings.py .world.tests.TestObj + +You can also run a specific test: + + evennia test --settings settings.py .world.tests.TestObj.test_alternative_call + +You might also want to read the [Python documentation for the unittest module](https://docs.python.org/library/unittest.html). + +## Using the Evennia testing classes + +Evennia offers many custom testing classes that helps with testing Evennia features. +They are all found in [evennia.utils.test_resources](evennia.utils.test_resources). Note that +these classes implement the `setUp` and `tearDown` already, so if you want to add stuff in them +yourself you should remember to use e.g. `super().setUp()` in your code. + +### Classes for testing your game dir + +These all use whatever setting you pass to them and works well for testing code in your game dir. + +- `EvenniaTest` - this sets up a full object environment for your test. All the created entities + can be accesses as properties on the class: + - `.account` - A fake [Account](evennia.accounts.accounts.DefaultAccount) named "TestAccount". + - `.account2` - Another account named "TestAccount2" + - `char1` - A [Character](evennia.objects.objects.DefaultCharacter) linked to `.account`, named `Char`. + This has 'Developer' permissions but is not a superuser. + - `.char2` - Another character linked to `account`, named `Char2`. This has base permissions (player). + - `.obj1` - A regular [Object](evennia.objects.objects.DefaultObject) named "Obj". + - `.obj2` - Another object named "Obj2". + - `.room1` - A [Room](evennia.objects.objects.DefaultRoom) named "Room". Both characters and both + objects are located inside this room. It has a description of "room_desc". + - `.room2` - Another room named "Room2". It is empty and has no set description. + - `.exit` - An exit named "out" that leads from `.room1` to `.room2`. + - `.script` - A [Script](evennia.scripts.scripts.DefaultScript) named "Script". It's an inert script + without a timing component. + - `.session` - A fake [Session](evennia.server.serversession.ServerSession) that mimics a player + connecting to the game. It is used by `.account1` and has a sessid of 1. +- `EvenniaCommandTest` - has the same environment like `EvenniaTest` but also adds a special + [.call()](evennia.utils.test_resources.EvenniaCommandTestMixin.call) method specifically for + testing Evennia [Commands](Commands.md). It allows you to compare what the command _actually_ + returns to the player with what you expect. Read the `call` api doc for more info. +- `EvenniaTestCase` - This is identical to the regular Python `TestCase` class, it's + just there for naming symmetry with `BaseEvenniaTestCase` below. + +Here's an example of using `EvenniaTest` ```python # in a test module -from evennia.utils.test_resources import BaseEvenniaTest +from evennia.utils.test_resources import EvenniaTest - -class TestObject(BaseEvenniaTest): - def test_object_search(self): - # char1 and char2 are both created in room1 +class TestObject(EvenniaTest): + """Remember that the testing class creates char1 and char2 inside room1 ...""" + def test_object_search_character(self): + """Check that char1 can search for char2 by name""" self.assertEqual(self.char1.search(self.char2.key), self.char2) + + def test_location_search(self): + """Check so that char1 can find the current location by name""" self.assertEqual(self.char1.search(self.char1.location.key), self.char1.location) # ... ``` -### Testing in-game Commands - -In-game Commands are a special case. Tests for the default commands are put in -`evennia/commands/default/tests.py`. This uses a custom `CommandTest` class that inherits from -`evennia.utils.test_resources.EvenniaTest` described above. `CommandTest` supplies extra convenience -functions for executing commands and check that their return values (calls of `msg()` returns -expected values. It uses Characters and Sessions generated on the `EvenniaTest` class to call each -class). - -Each command tested should have its own `TestCase` class. Inherit this class from the `CommandTest` -class in the same module to get access to the command-specific utilities mentioned. +This example tests a custom command. ```python - from evennia.commands.default.tests import CommandTest - from evennia.commands.default import general - class TestSet(CommandTest): - "tests the look command by simple call, using Char2 as a target" - def test_mycmd_char(self): - self.call(general.CmdLook(), "Char2", "Char2(#7)") + from evennia.commands.default.tests import EvenniaCommandTest +from commands import command as mycommand + + +class TestSet(EvenniaCommandTest): + "tests the look command by simple call, using Char2 as a target" + + def test_mycmd_char(self): + self.call(mycommand.CmdMyLook(), "Char2", "Char2(#7)") + + def test_mycmd_room(self): "tests the look command by simple call, with target as room" - def test_mycmd_room(self): - self.call(general.CmdLook(), "Room", - "Room(#1)\nroom_desc\nExits: out(#3)\n" - "You see: Obj(#4), Obj2(#5), Char2(#7)") + self.call(mycommand.CmdMyLook(), "Room", + "Room(#1)\nroom_desc\nExits: out(#3)\n" + "You see: Obj(#4), Obj2(#5), Char2(#7)") ``` -### Unit testing contribs with custom models +When using `.call`, you don't need to specify the entire string; you can just give the beginning +of it and if it matches, that's enough. Use `\n` to denote line breaks and (this is a special for +the `.call` helper), `||` to indicate multiple uses of `.msg()` in the Command. The `.call` helper +has a lot of arguments for mimicing different ways of calling a Command, so make sure to +[read the API docs for .call()](evennia.utils.test_resources.EvenniaCommandTestMixin.call). + +### Classes for testing Evennia core + +These are used for testing Evennia itself. They provide the same resources as the classes +above but enforce Evennias default settings found in `evennia/settings_default.py`, ignoring +any settings changes in your game dir. + +- `BaseEvenniaTest` - all the default objects above but with enforced default settings +- `BaseEvenniaCommandTest` - for testing Commands, but with enforced default settings +- `BaseEvenniaTestCase` - no default objects, only enforced default settings + +There are also two special 'mixin' classes. These are uses in the classes above, but may also +be useful if you want to mix your own testing classes: + +- `EvenniaTestMixin` - A class mixin that creates all test environment objects. +- `EvenniaCommandMixin` - A class mixin that adds the `.call()` Command-tester helper. + +If you want to help out writing unittests for Evennia, take a look at Evennia's [coveralls.io +page](https://coveralls.io/github/evennia/evennia). There you see which modules have any form of +test coverage and which does not. All help is appreciated! + +## Unit testing contribs with custom models A special case is if you were to create a contribution to go to the `evennia/contrib` folder that uses its [own database models](../Concepts/New-Models.md). The problem with this is that Evennia (and Django) will @@ -216,14 +291,8 @@ class TestMyModel(BaseEvenniaTest): # test case here ``` -### A note on adding new tests -Having an extensive tests suite is very important for avoiding code degradation as Evennia is -developed. Only a small fraction of the Evennia codebase is covered by test suites at this point. -Writing new tests is not hard, it's more a matter of finding the time to do so. So adding new tests -is really an area where everyone can contribute, also with only limited Python skills. - -### A note on making the test runner faster +## A note on making the test runner faster If you have custom models with a large number of migrations, creating the test database can take a very long time. If you don't require migrations to run for your tests, you can disable them with the @@ -246,156 +315,4 @@ After doing so, you can then run tests without migrations by adding the `--nomig ``` evennia test --settings settings.py --nomigrations . -``` - -## Testing for Game development (mini-tutorial) - -Unit testing can be of paramount importance to game developers. When starting with a new game, it is -recommended to look into unit testing as soon as possible; an already huge game is much harder to -write tests for. The benefits of testing a game aren't different from the ones regarding library -testing. For example it is easy to introduce bugs that affect previously working code. Testing is -there to ensure your project behaves the way it should and continue to do so. - -If you have never used unit testing (with Python or another language), you might want to check the -[official Python documentation about unit testing](https://docs.python.org/2/library/unittest.html), -particularly the first section dedicated to a basic example. - -### Basic testing using Evennia - -Evennia's test runner can be used to launch tests in your game directory (let's call it 'mygame'). -Evennia's test runner does a few useful things beyond the normal Python unittest module: - -* It creates and sets up an empty database, with some useful objects (accounts, characters and -rooms, among others). -* It provides simple ways to test commands, which can be somewhat tricky at times, if not tested -properly. - -Therefore, you should use the command-line to execute the test runner, while specifying your own -game directories (not the one containing evennia). Go to your game directory (referred as 'mygame' -in this section) and execute the test runner: - - evennia --settings settings.py test commands - -This command will execute Evennia's test runner using your own settings file. It will set up a dummy -database of your choice and look into the 'commands' package defined in your game directory -(`mygame/commands` in this example) to find tests. The test module's name should begin with 'test' -and contain one or more `TestCase`. A full example can be found below. - -### A simple example - -In your game directory, go to `commands` and create a new file `tests.py` inside (it could be named -anything starting with `test`). We will start by making a test that has nothing to do with Commands, -just to show how unit testing works: - -```python - # mygame/commands/tests.py - - import unittest - - class TestString(unittest.TestCase): - - """Unittest for strings (just a basic example).""" - - def test_upper(self): - """Test the upper() str method.""" - self.assertEqual('foo'.upper(), 'FOO') -``` - -This example, inspired from the Python documentation, is used to test the 'upper()' method of the -'str' class. Not very useful, but it should give you a basic idea of how tests are used. - -Let's execute that test to see if it works. - - > evennia --settings settings.py test commands - - TESTING: Using specified settings file 'server.conf.settings'. - - (Obs: Evennia's full test suite may not pass if the settings are very - different from the default. Use 'test .' as arguments to run only tests - on the game dir.) - - Creating test database for alias 'default'... - . - ---------------------------------------------------------------------- - Ran 1 test in 0.001s - - OK - Destroying test database for alias 'default'... - -We specified the `commands` package to the evennia test command since that's where we put our test -file. In this case we could just as well just said `.` to search all of `mygame` for testing files. -If we have a lot of tests it may be useful to test only a single set at a time though. We get an -information text telling us we are using our custom settings file (instead of Evennia's default -file) and then the test runs. The test passes! Change the "FOO" string to something else in the test -to see how it looks when it fails. - -### Testing commands - -```{warning} This is not correct anymore. -``` - -This section will test the proper execution of the 'abilities' command, as described in the DELETED -tutorial to create the 'abilities' command, we will need it to test it. - -Testing commands in Evennia is a bit more complex than the simple testing example we have seen. -Luckily, Evennia supplies a special test class to do just that ... we just need to inherit from it -and use it properly. This class is called 'CommandTest' and is defined in the -'evennia.commands.default.tests' package. To create a test for our 'abilities' command, we just -need to create a class that inherits from 'CommandTest' and add methods. - -We could create a new test file for this but for now we just append to the `tests.py` file we -already have in `commands` from before. - -```python - # bottom of mygame/commands/tests.py - - from evennia.commands.default.tests import CommandTest - - from commands.command import CmdAbilities - from typeclasses.characters import Character - - class TestAbilities(CommandTest): - - character_typeclass = Character - - def test_simple(self): - self.call(CmdAbilities(), "", "STR: 5, AGI: 4, MAG: 2") -``` - -* Line 1-4: we do some importing. 'CommandTest' is going to be our base class for our test, so we -need it. We also import our command ('CmdAbilities' in this case). Finally we import the -'Character' typeclass. We need it, since 'CommandTest' doesn't use 'Character', but -'DefaultCharacter', which means the character calling the command won't have the abilities we have -written in the 'Character' typeclass. -* Line 6-8: that's the body of our test. Here, a single command is tested in an entire class. -Default commands are usually grouped by category in a single class. There is no rule, as long as -you know where you put your tests. Note that we set the 'character_typeclass' class attribute to -Character. As explained above, if you didn't do that, the system would create a 'DefaultCharacter' -object, not a 'Character'. You can try to remove line 4 and 8 to see what happens when running the -test. -* Line 10-11: our unique testing method. Note its name: it should begin by 'test_'. Apart from -that, the method is quite simple: it's an instance method (so it takes the 'self' argument) but no -other arguments are needed. Line 11 uses the 'call' method, which is defined in 'CommandTest'. -It's a useful method that compares a command against an expected result. It would be like comparing -two strings with 'assertEqual', but the 'call' method does more things, including testing the -command in a realistic way (calling its hooks in the right order, so you don't have to worry about -that). - -Line 11 can be understood as: test the 'abilities' command (first parameter), with no argument -(second parameter), and check that the character using it receives his/her abilities (third -parameter). - -Let's run our new test: - - > evennia --settings settings.py test commands - [...] - Creating test database for alias 'default'... - .. - ---------------------------------------------------------------------- - Ran 2 tests in 0.156s - - OK - Destroying test database for alias 'default'... - -Two tests were executed, since we have kept 'TestString' from last time. In case of failure, you -will get much more information to help you fix the bug. +``` \ No newline at end of file diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 0e0d382b8f..2ab0e19ea5 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -11,8 +11,6 @@ main test suite started with > python game/manage.py test. """ -import re -import types import datetime from anything import Anything @@ -23,7 +21,7 @@ from unittest.mock import patch, Mock, MagicMock from evennia import DefaultRoom, DefaultExit, ObjectDB from evennia.commands.default.cmdset_character import CharacterCmdSet -from evennia.utils.test_resources import BaseEvenniaTest, EvenniaTest +from evennia.utils.test_resources import BaseEvenniaTest, BaseEvenniaCommandTest, EvenniaCommandTest # noqa from evennia.commands.default import ( help as help_module, general, @@ -40,305 +38,18 @@ from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.command import Command, InterruptCommand from evennia.commands import cmdparser from evennia.commands.cmdset import CmdSet -from evennia.utils import ansi, utils, gametime, create +from evennia.utils import utils, gametime, create from evennia.server.sessionhandler import SESSIONS from evennia import search_object from evennia import DefaultObject, DefaultCharacter from evennia.prototypes import prototypes as protlib - -# set up signal here since we are not starting the server - -_RE_STRIP_EVMENU = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE) - - # ------------------------------------------------------------ # Command testing # ------------------------------------------------------------ -@patch("evennia.server.portal.portal.LoopingCall", new=MagicMock()) -class CommandTestMixin: - """ - Mixin to add to a test in order to provide the `.call` helper for - testing the execution and returns of a command. - Tests a Command by running it and comparing what messages it sends with - expected values. This tests without actually spinning up the cmdhandler - for every test, which is more controlled. - - Example: - :: - - from commands.echo import CmdEcho - - class MyCommandTest(EvenniaTest, CommandTestMixin): - - def test_echo(self): - ''' - Test that the echo command really returns - what you pass into it. - ''' - self.call(MyCommand(), "hello world!", - "You hear your echo: 'Hello world!'") - - """ - - # formatting for .call's error message - _ERROR_FORMAT = """ -=========================== Wanted message =================================== -{expected_msg} -=========================== Returned message ================================= -{returned_msg} -============================================================================== -""".rstrip() - - def call( - self, - cmdobj, - input_args, - msg=None, - cmdset=None, - noansi=True, - caller=None, - receiver=None, - cmdstring=None, - obj=None, - inputs=None, - raw_string=None, - ): - """ - Test a command by assigning all the needed properties to a cmdobj and - running the sequence. The resulting `.msg` calls will be mocked and - the text= calls to them compared to a expected output. - - Args: - cmdobj (Command): The command object to use. - input_args (str): This should be the full input the Command should - see, such as 'look here'. This will become `.args` for the Command - instance to parse. - msg (str or dict, optional): This is the expected return value(s) - returned through `caller.msg(text=...)` calls in the command. If a string, the - receiver is controlled with the `receiver` kwarg (defaults to `caller`). - If this is a `dict`, it is a mapping - `{receiver1: "expected1", receiver2: "expected2",...}` and `receiver` is - ignored. The message(s) are compared with the actual messages returned - to the receiver(s) as the Command runs. Each check uses `.startswith`, - so you can choose to only include the first part of the - returned message if that's enough to verify a correct result. EvMenu - decorations (like borders) are stripped and should not be included. This - should also not include color tags unless `noansi=False`. - If the command returns texts in multiple separate `.msg`- - calls to a receiver, separate these with `|` if `noansi=True` - (default) and `||` if `noansi=False`. If no `msg` is given (`None`), - then no automatic comparison will be done. - cmdset (str, optional): If given, make `.cmdset` available on the Command - instance as it runs. While `.cmdset` is normally available on the - Command instance by default, this is usually only used by - commands that explicitly operates/displays cmdsets, like - `examine`. - noansi (str, optional): By default the color tags of the `msg` is - ignored, this makes them significant. If unset, `msg` must contain - the same color tags as the actual return message. - caller (Object or Account, optional): By default `self.char1` is used as the - command-caller (the `.caller` property on the Command). This allows to - execute with another caller, most commonly an Account. - receiver (Object or Account, optional): This is the object to receive the - return messages we want to test. By default this is the same as `caller` - (which in turn defaults to is `self.char1`). Note that if `msg` is - a `dict`, this is ignored since the receiver is already specified there. - cmdstring (str, optional): Normally this is the Command's `key`. - This allows for tweaking the `.cmdname` property of the - Command`. This isb used for commands with multiple aliases, - where the command explicitly checs which alias was used to - determine its functionality. - obj (str, optional): This sets the `.obj` property of the Command - the - object on which the Command 'sits'. By default this is the same as `caller`. - This can be used for testing on-object Command interactions. - inputs (list, optional): A list of strings to pass to functions that pause to - take input from the user (normally using `@interactive` and - `ret = yield(question)` or `evmenu.get_input`). Each element of the - list will be passed into the command as if the user wrote that at the prompt. - raw_string (str, optional): Normally the `.raw_string` property is set as - a combination of your `key/cmdname` and `input_args`. This allows - direct control of what this is, for example for testing edge cases - or malformed inputs. - - Returns: - str or dict: The message sent to `receiver`, or a dict of - `{receiver: "msg", ...}` if multiple are given. This is usually - only used with `msg=None` to do the validation externally. - - Raises: - AssertionError: If the returns of `.msg` calls (tested with `.startswith`) does not - match `expected_input`. - - Notes: - As part of the tests, all methods of the Command will be called in - the proper order: - - - cmdobj.at_pre_cmd() - - cmdobj.parse() - - cmdobj.func() - - cmdobj.at_post_cmd() - - """ - # The `self.char1` is created in the `EvenniaTest` base along with - # other helper objects like self.room and self.obj - caller = caller if caller else self.char1 - cmdobj.caller = caller - cmdobj.cmdname = cmdstring if cmdstring else cmdobj.key - cmdobj.raw_cmdname = cmdobj.cmdname - cmdobj.cmdstring = cmdobj.cmdname # deprecated - cmdobj.args = input_args - cmdobj.cmdset = cmdset - cmdobj.session = SESSIONS.session_from_sessid(1) - cmdobj.account = self.account - cmdobj.raw_string = raw_string if raw_string is not None else cmdobj.key + " " + input_args - cmdobj.obj = obj or (caller if caller else self.char1) - inputs = inputs or [] - - # set up receivers - receiver_mapping = {} - if isinstance(msg, dict): - # a mapping {receiver: msg, ...} - receiver_mapping = {recv: str(msg).strip() if msg else None - for recv, msg in msg.items()} - else: - # a single expected string and thus a single receiver (defaults to caller) - receiver = receiver if receiver else caller - receiver_mapping[receiver] = str(msg).strip() if msg is not None else None - - unmocked_msg_methods = {} - for receiver in receiver_mapping: - # save the old .msg method so we can get it back - # cleanly after the test - unmocked_msg_methods[receiver] = receiver.msg - # replace normal `.msg` with a mock - receiver.msg = Mock() - - # Run the methods of the Command. This mimics what happens in the - # cmdhandler. This will have the mocked .msg be called as part of the - # execution. Mocks remembers what was sent to them so we will be able - # to retrieve what was sent later. - try: - if cmdobj.at_pre_cmd(): - return - cmdobj.parse() - ret = cmdobj.func() - - # handle func's with yield in them (making them generators) - if isinstance(ret, types.GeneratorType): - while True: - try: - inp = inputs.pop() if inputs else None - if inp: - try: - # this mimics a user's reply to a prompt - ret.send(inp) - except TypeError: - next(ret) - ret = ret.send(inp) - else: - # non-input yield, like yield(10). We don't pause - # but fire it immediately. - next(ret) - except StopIteration: - break - - cmdobj.at_post_cmd() - except StopIteration: - pass - except InterruptCommand: - pass - - for inp in inputs: - # if there are any inputs left, we may have a non-generator - # input to handle (get_input/ask_yes_no that uses a separate - # cmdset rather than a yield - caller.execute_cmd(inp) - - # At this point the mocked .msg methods on each receiver will have - # stored all calls made to them (that's a basic function of the Mock - # class). We will not extract them and compare to what we expected to - # go to each receiver. - - returned_msgs = {} - for receiver, expected_msg in receiver_mapping.items(): - # get the stored messages from the Mock with Mock.mock_calls. - stored_msg = [ - args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs)) - for name, args, kwargs in receiver.msg.mock_calls - ] - # we can return this now, we are done using the mock - receiver.msg = unmocked_msg_methods[receiver] - - # Get the first element of a tuple if msg received a tuple instead of a string - stored_msg = [str(smsg[0]) - if isinstance(smsg, tuple) else str(smsg) for smsg in stored_msg] - if expected_msg is None: - # no expected_msg; just build the returned_msgs dict - - returned_msg = "\n".join(str(msg) for msg in stored_msg) - returned_msgs[receiver] = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip() - else: - # compare messages to expected - - # set our separator for returned messages based on parsing ansi or not - msg_sep = "|" if noansi else "||" - - # We remove Evmenu decorations since that just makes it harder - # to write the comparison string. We also strip ansi before this - # comparison since otherwise it would mess with the regex. - returned_msg = msg_sep.join( - _RE_STRIP_EVMENU.sub( - "", ansi.parse_ansi(mess, strip_ansi=noansi)) - for mess in stored_msg).strip() - - # this is the actual test - if expected_msg == "" and returned_msg or not returned_msg.startswith(expected_msg): - # failed the test - raise AssertionError( - self._ERROR_FORMAT.format( - expected_msg=expected_msg, returned_msg=returned_msg) - ) - # passed! - returned_msgs[receiver] = returned_msg - - if len(returned_msgs) == 1: - return list(returned_msgs.values())[0] - return returned_msgs - - -@patch("evennia.commands.account.COMMAND_DEFAULT_CLASS", MuxCommand) -@patch("evennia.commands.admin.COMMAND_DEFAULT_CLASS", MuxCommand) -@patch("evennia.commands.batchprocess.COMMAND_DEFAULT_CLASS", MuxCommand) -@patch("evennia.commands.building.COMMAND_DEFAULT_CLASS", MuxCommand) -@patch("evennia.commands.comms.COMMAND_DEFAULT_CLASS", MuxCommand) -@patch("evennia.commands.general.COMMAND_DEFAULT_CLASS", MuxCommand) -@patch("evennia.commands.help.COMMAND_DEFAULT_CLASS", MuxCommand) -@patch("evennia.commands.syscommands.COMMAND_DEFAULT_CLASS", MuxCommand) -@patch("evennia.commands.system.COMMAND_DEFAULT_CLASS", MuxCommand) -@patch("evennia.commands.unloggedin.COMMAND_DEFAULT_CLASS", MuxCommand) -class EvenniaCommandTest(BaseEvenniaTest, CommandTestMixin): - """ - Commands only using the default settings. - - """ - - -class CommandTest(EvenniaTest, CommandTestMixin): - """ - Parent class to inherit from - makes tests use your own - classes and settings in mygame. - - """ - -# ------------------------------------------------------------ -# Individual module Tests -# ------------------------------------------------------------ - - -class TestGeneral(EvenniaCommandTest): +class TestGeneral(BaseEvenniaCommandTest): def test_look(self): rid = self.room1.id self.call(general.CmdLook(), "here", "Room(#{})\nroom_desc".format(rid)) @@ -434,7 +145,7 @@ class TestGeneral(EvenniaCommandTest): self.call(general.CmdAccess(), "", "Permission Hierarchy (climbing):") -class TestHelp(EvenniaCommandTest): +class TestHelp(BaseEvenniaCommandTest): maxDiff = None @@ -584,7 +295,7 @@ class TestHelp(EvenniaCommandTest): cmdset=TestCmdSet()) -class TestSystem(EvenniaCommandTest): +class TestSystem(BaseEvenniaCommandTest): def test_py(self): # we are not testing CmdReload, CmdReset and CmdShutdown, CmdService or CmdTime # since the server is not running during these tests. @@ -608,7 +319,7 @@ _TASK_HANDLER = None def func_test_cmd_tasks(): return 'success' -class TestCmdTasks(EvenniaCommandTest): +class TestCmdTasks(BaseEvenniaCommandTest): def setUp(self): super().setUp() @@ -768,7 +479,7 @@ class TestCmdTasks(EvenniaCommandTest): self.call(system.CmdTasks(), f'/cancel', wanted_msg) -class TestAdmin(EvenniaCommandTest): +class TestAdmin(BaseEvenniaCommandTest): def test_emit(self): self.call(admin.CmdEmit(), "Char2 = Test", "Emitted to Char2:\nTest") @@ -799,7 +510,7 @@ class TestAdmin(EvenniaCommandTest): ) -class TestAccount(EvenniaCommandTest): +class TestAccount(BaseEvenniaCommandTest): def test_ooc_look(self): if settings.MULTISESSION_MODE < 2: self.call( @@ -923,7 +634,7 @@ class TestAccount(EvenniaCommandTest): ) -class TestBuilding(EvenniaCommandTest): +class TestBuilding(BaseEvenniaCommandTest): def test_create(self): name = settings.BASE_OBJECT_TYPECLASS.rsplit(".", 1)[1] self.call( @@ -1991,7 +1702,7 @@ from evennia.comms.comms import DefaultChannel # noqa @patch("evennia.commands.default.comms.CHANNEL_DEFAULT_TYPECLASS", DefaultChannel) -class TestCommsChannel(EvenniaCommandTest): +class TestCommsChannel(BaseEvenniaCommandTest): """ Test the central `channel` command. @@ -2214,7 +1925,7 @@ class TestCommsChannel(EvenniaCommandTest): from evennia.commands.default import comms # noqa -class TestComms(EvenniaCommandTest): +class TestComms(BaseEvenniaCommandTest): def test_page(self): self.call( @@ -2226,7 +1937,7 @@ class TestComms(EvenniaCommandTest): ) -class TestBatchProcess(EvenniaCommandTest): +class TestBatchProcess(BaseEvenniaCommandTest): """ Test the batch processor. @@ -2262,13 +1973,13 @@ class CmdInterrupt(Command): self.msg("in func") -class TestInterruptCommand(EvenniaCommandTest): +class TestInterruptCommand(BaseEvenniaCommandTest): def test_interrupt_command(self): ret = self.call(CmdInterrupt(), "") self.assertEqual(ret, "") -class TestUnconnectedCommand(EvenniaCommandTest): +class TestUnconnectedCommand(BaseEvenniaCommandTest): def test_info_command(self): # instead of using SERVER_START_TIME (0), we use 86400 because Windows won't let us use anything lower gametime.SERVER_START_TIME = 86400 @@ -2288,7 +1999,7 @@ class TestUnconnectedCommand(EvenniaCommandTest): # Test syscommands -class TestSystemCommands(EvenniaCommandTest): +class TestSystemCommands(BaseEvenniaCommandTest): def test_simple_defaults(self): self.call(syscommands.SystemNoInput(), "") self.call(syscommands.SystemNoMatch(), "Huh?") diff --git a/evennia/contrib/base_systems/building_menu/tests.py b/evennia/contrib/base_systems/building_menu/tests.py index e8c04a2951..96d534908f 100644 --- a/evennia/contrib/base_systems/building_menu/tests.py +++ b/evennia/contrib/base_systems/building_menu/tests.py @@ -3,7 +3,7 @@ Building menu tests. """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from . building_menu import BuildingMenu, CmdNoMatch @@ -12,7 +12,7 @@ class Submenu(BuildingMenu): self.add_choice("title", key="t", attr="key") -class TestBuildingMenu(EvenniaCommandTest): +class TestBuildingMenu(BaseEvenniaCommandTest): def setUp(self): super(TestBuildingMenu, self).setUp() self.menu = BuildingMenu(caller=self.char1, obj=self.room1, title="test") diff --git a/evennia/contrib/base_systems/email_login/tests.py b/evennia/contrib/base_systems/email_login/tests.py index f9999256ed..20b203ee1c 100644 --- a/evennia/contrib/base_systems/email_login/tests.py +++ b/evennia/contrib/base_systems/email_login/tests.py @@ -3,11 +3,11 @@ Test email login. """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from . import email_login -class TestEmailLogin(EvenniaCommandTest): +class TestEmailLogin(BaseEvenniaCommandTest): def test_connect(self): self.call( email_login.CmdUnconnectedConnect(), diff --git a/evennia/contrib/base_systems/ingame_python/tests.py b/evennia/contrib/base_systems/ingame_python/tests.py index 7dd02751e2..ddcee13d1d 100644 --- a/evennia/contrib/base_systems/ingame_python/tests.py +++ b/evennia/contrib/base_systems/ingame_python/tests.py @@ -7,7 +7,7 @@ from textwrap import dedent from django.conf import settings from evennia import ScriptDB -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.objects.objects import ExitCommand from evennia.utils import ansi, utils from evennia.utils.create import create_object, create_script @@ -246,7 +246,7 @@ class TestEventHandler(BaseEvenniaTest): self.assertEqual(self.room1.callbacks.all(), {}) -class TestCmdCallback(EvenniaCommandTest): +class TestCmdCallback(BaseEvenniaCommandTest): """Test the @callback command.""" @@ -425,7 +425,7 @@ class TestCmdCallback(EvenniaCommandTest): self.assertEqual(callback.valid, True) -class TestDefaultCallbacks(EvenniaCommandTest): +class TestDefaultCallbacks(BaseEvenniaCommandTest): """Test the default callbacks.""" diff --git a/evennia/contrib/base_systems/menu_login/tests.py b/evennia/contrib/base_systems/menu_login/tests.py index 144176081d..347867ac4f 100644 --- a/evennia/contrib/base_systems/menu_login/tests.py +++ b/evennia/contrib/base_systems/menu_login/tests.py @@ -3,10 +3,10 @@ Test menu_login """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from . import menu_login -class TestMenuLogin(EvenniaCommandTest): +class TestMenuLogin(BaseEvenniaCommandTest): def test_cmdunloggedlook(self): self.call(menu_login.CmdUnloggedinLook(), "", "======") diff --git a/evennia/contrib/base_systems/mux_comms_cmds/tests.py b/evennia/contrib/base_systems/mux_comms_cmds/tests.py index 3aa9a785d5..17ee835c66 100644 --- a/evennia/contrib/base_systems/mux_comms_cmds/tests.py +++ b/evennia/contrib/base_systems/mux_comms_cmds/tests.py @@ -3,11 +3,11 @@ Legacy Mux comms tests (extracted from 0.9.5) """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from . import mux_comms_cmds as comms -class TestLegacyMuxComms(EvenniaCommandTest): +class TestLegacyMuxComms(BaseEvenniaCommandTest): """ Test the legacy comms contrib. """ diff --git a/evennia/contrib/base_systems/unixcommand/tests.py b/evennia/contrib/base_systems/unixcommand/tests.py index e92768e75c..df779e5997 100644 --- a/evennia/contrib/base_systems/unixcommand/tests.py +++ b/evennia/contrib/base_systems/unixcommand/tests.py @@ -3,7 +3,7 @@ Test of the Unixcommand. """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from .unixcommand import UnixCommand @@ -30,7 +30,7 @@ class CmdDummy(UnixCommand): self.msg("{} * {} = {}".format(nb1, nb2, result)) -class TestUnixCommand(EvenniaCommandTest): +class TestUnixCommand(BaseEvenniaCommandTest): def test_success(self): """See the command parsing succeed.""" self.call(CmdDummy(), "5 10", "5 * 10 = 50") diff --git a/evennia/contrib/full_systems/evscaperoom/tests.py b/evennia/contrib/full_systems/evscaperoom/tests.py index 25ffb5c196..98a0e85f8c 100644 --- a/evennia/contrib/full_systems/evscaperoom/tests.py +++ b/evennia/contrib/full_systems/evscaperoom/tests.py @@ -5,7 +5,7 @@ Unit tests for the Evscaperoom import inspect import pkgutil from os import path -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia import InterruptCommand from evennia.utils.test_resources import BaseEvenniaTest from evennia.utils import mod_import @@ -15,7 +15,7 @@ from . import objects from . import utils -class TestEvscaperoomCommands(EvenniaCommandTest): +class TestEvscaperoomCommands(BaseEvenniaCommandTest): def setUp(self): super().setUp() self.room1 = utils.create_evscaperoom_object("evscaperoom.room.EvscapeRoom", key="Testroom") diff --git a/evennia/contrib/game_systems/barter/tests.py b/evennia/contrib/game_systems/barter/tests.py index c50e3dbdde..547d0f7fc7 100644 --- a/evennia/contrib/game_systems/barter/tests.py +++ b/evennia/contrib/game_systems/barter/tests.py @@ -3,12 +3,12 @@ Test the contrib barter system """ from mock import Mock -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.create import create_object from . import barter -class TestBarter(EvenniaCommandTest): +class TestBarter(BaseEvenniaCommandTest): def setUp(self): super().setUp() self.tradeitem1 = create_object(key="TradeItem1", location=self.char1) diff --git a/evennia/contrib/game_systems/clothing/tests.py b/evennia/contrib/game_systems/clothing/tests.py index 102243d520..6ea80f3246 100644 --- a/evennia/contrib/game_systems/clothing/tests.py +++ b/evennia/contrib/game_systems/clothing/tests.py @@ -3,14 +3,14 @@ Testing clothing contrib """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.create import create_object from evennia.objects.objects import DefaultRoom from evennia.utils.test_resources import BaseEvenniaTest from . import clothing -class TestClothingCmd(EvenniaCommandTest): +class TestClothingCmd(BaseEvenniaCommandTest): def test_clothingcommands(self): wearer = create_object(clothing.ClothedCharacter, key="Wearer") friend = create_object(clothing.ClothedCharacter, key="Friend") diff --git a/evennia/contrib/game_systems/crafting/tests.py b/evennia/contrib/game_systems/crafting/tests.py index 7386f190f0..b28faaba78 100644 --- a/evennia/contrib/game_systems/crafting/tests.py +++ b/evennia/contrib/game_systems/crafting/tests.py @@ -6,7 +6,7 @@ Unit tests for the crafting system contrib. from unittest import mock from django.test import override_settings from django.core.exceptions import ObjectDoesNotExist -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.test_resources import BaseEvenniaTestCase from evennia.utils.create import create_object from . import crafting, example_recipes @@ -655,7 +655,7 @@ class TestCraftSword(BaseEvenniaTestCase): @mock.patch("evennia.contrib.game_systems.crafting.crafting._load_recipes", new=mock.MagicMock()) @mock.patch("evennia.contrib.game_systems.crafting.crafting._RECIPE_CLASSES", new={"testrecipe": _MockRecipe}) @override_settings(CRAFT_RECIPE_MODULES=[]) -class TestCraftCommand(EvenniaCommandTest): +class TestCraftCommand(BaseEvenniaCommandTest): """Test the crafting command""" def setUp(self): diff --git a/evennia/contrib/game_systems/gendersub/tests.py b/evennia/contrib/game_systems/gendersub/tests.py index 55aeaa71b0..b01bab163a 100644 --- a/evennia/contrib/game_systems/gendersub/tests.py +++ b/evennia/contrib/game_systems/gendersub/tests.py @@ -4,13 +4,13 @@ Test gendersub contrib. """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.create import create_object from mock import patch from . import gendersub -class TestGenderSub(EvenniaCommandTest): +class TestGenderSub(BaseEvenniaCommandTest): def test_setgender(self): self.call(gendersub.SetGender(), "male", "Your gender was set to male.") self.call(gendersub.SetGender(), "ambiguous", "Your gender was set to ambiguous.") diff --git a/evennia/contrib/game_systems/mail/tests.py b/evennia/contrib/game_systems/mail/tests.py index 0d357b8636..f545ce6f33 100644 --- a/evennia/contrib/game_systems/mail/tests.py +++ b/evennia/contrib/game_systems/mail/tests.py @@ -3,11 +3,11 @@ Test mail contrib """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from . import mail -class TestMail(EvenniaCommandTest): +class TestMail(BaseEvenniaCommandTest): def test_mail(self): self.call(mail.CmdMail(), "2", "'2' is not a valid mail id.", caller=self.account) self.call(mail.CmdMail(), "test", "'test' is not a valid mail id.", caller=self.account) diff --git a/evennia/contrib/game_systems/multidescer/tests.py b/evennia/contrib/game_systems/multidescer/tests.py index 4f43483979..13a40efffe 100644 --- a/evennia/contrib/game_systems/multidescer/tests.py +++ b/evennia/contrib/game_systems/multidescer/tests.py @@ -3,11 +3,11 @@ Test multidescer contrib. """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from . import multidescer -class TestMultidescer(EvenniaCommandTest): +class TestMultidescer(BaseEvenniaCommandTest): def test_cmdmultidesc(self): self.call(multidescer.CmdMultiDesc(), "/list", "Stored descs:\ncaller:") self.call( diff --git a/evennia/contrib/game_systems/puzzles/tests.py b/evennia/contrib/game_systems/puzzles/tests.py index 43c11cb7e5..e6f26ff2dd 100644 --- a/evennia/contrib/game_systems/puzzles/tests.py +++ b/evennia/contrib/game_systems/puzzles/tests.py @@ -9,12 +9,12 @@ import re import itertools from mock import Mock from evennia.utils import search -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.create import create_object from . import puzzles -class TestPuzzles(EvenniaCommandTest): +class TestPuzzles(BaseEvenniaCommandTest): def setUp(self): super(TestPuzzles, self).setUp() self.steel = create_object(self.object_typeclass, key="steel", location=self.char1.location) diff --git a/evennia/contrib/game_systems/turnbattle/tests.py b/evennia/contrib/game_systems/turnbattle/tests.py index e180d2b1d5..47bbfc847c 100644 --- a/evennia/contrib/game_systems/turnbattle/tests.py +++ b/evennia/contrib/game_systems/turnbattle/tests.py @@ -4,14 +4,14 @@ Turnbattle tests. """ from mock import patch, MagicMock -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.create import create_object from evennia.utils.test_resources import BaseEvenniaTest from evennia.objects.objects import DefaultRoom from . import tb_basic, tb_equip, tb_range, tb_items, tb_magic -class TestTurnBattleBasicCmd(EvenniaCommandTest): +class TestTurnBattleBasicCmd(BaseEvenniaCommandTest): # Test basic combat commands def test_turnbattlecmd(self): @@ -22,7 +22,7 @@ class TestTurnBattleBasicCmd(EvenniaCommandTest): self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.") -class TestTurnBattleEquipCmd(EvenniaCommandTest): +class TestTurnBattleEquipCmd(BaseEvenniaCommandTest): def setUp(self): super(TestTurnBattleEquipCmd, self).setUp() self.testweapon = create_object(tb_equip.TBEWeapon, key="test weapon") @@ -45,7 +45,7 @@ class TestTurnBattleEquipCmd(EvenniaCommandTest): self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.") -class TestTurnBattleRangeCmd(EvenniaCommandTest): +class TestTurnBattleRangeCmd(BaseEvenniaCommandTest): # Test range commands def test_turnbattlerangecmd(self): # Start with range module specific commands. @@ -61,7 +61,7 @@ class TestTurnBattleRangeCmd(EvenniaCommandTest): self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") -class TestTurnBattleItemsCmd(EvenniaCommandTest): +class TestTurnBattleItemsCmd(BaseEvenniaCommandTest): def setUp(self): super(TestTurnBattleItemsCmd, self).setUp() self.testitem = create_object(key="test item") @@ -78,7 +78,7 @@ class TestTurnBattleItemsCmd(EvenniaCommandTest): self.call(tb_items.CmdRest(), "", "Char rests to recover HP.") -class TestTurnBattleMagicCmd(EvenniaCommandTest): +class TestTurnBattleMagicCmd(BaseEvenniaCommandTest): # Test magic commands def test_turnbattlemagiccmd(self): diff --git a/evennia/contrib/grid/extended_room/tests.py b/evennia/contrib/grid/extended_room/tests.py index 54ce11aced..23df82c2f7 100644 --- a/evennia/contrib/grid/extended_room/tests.py +++ b/evennia/contrib/grid/extended_room/tests.py @@ -6,7 +6,7 @@ Testing of ExtendedRoom contrib import datetime from mock import patch, Mock from django.conf import settings -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.objects.objects import DefaultRoom from . import extended_room @@ -24,7 +24,7 @@ class ForceUTCDatetime(datetime.datetime): @patch("evennia.contrib.grid.extended_room.extended_room.datetime.datetime", ForceUTCDatetime) # mock gametime to return April 9, 2064, at 21:06 (spring evening) @patch("evennia.utils.gametime.gametime", new=Mock(return_value=2975000766)) -class TestExtendedRoom(EvenniaCommandTest): +class TestExtendedRoom(BaseEvenniaCommandTest): room_typeclass = extended_room.ExtendedRoom DETAIL_DESC = "A test detail." SPRING_DESC = "A spring description." diff --git a/evennia/contrib/grid/mapbuilder/tests.py b/evennia/contrib/grid/mapbuilder/tests.py index 4ac852afed..2a62072749 100644 --- a/evennia/contrib/grid/mapbuilder/tests.py +++ b/evennia/contrib/grid/mapbuilder/tests.py @@ -3,7 +3,7 @@ Test map builder. """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from . import mapbuilder # -*- coding: utf-8 -*- @@ -187,7 +187,7 @@ EXAMPLE2_LEGEND = { } -class TestMapBuilder(EvenniaCommandTest): +class TestMapBuilder(BaseEvenniaCommandTest): def test_cmdmapbuilder(self): self.call( mapbuilder.CmdMapBuilder(), diff --git a/evennia/contrib/grid/simpledoor/tests.py b/evennia/contrib/grid/simpledoor/tests.py index 2a5083bf10..31c632e4ad 100644 --- a/evennia/contrib/grid/simpledoor/tests.py +++ b/evennia/contrib/grid/simpledoor/tests.py @@ -4,11 +4,11 @@ Tests of simpledoor. """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from . import simpledoor -class TestSimpleDoor(EvenniaCommandTest): +class TestSimpleDoor(BaseEvenniaCommandTest): def test_cmdopen(self): self.call( simpledoor.CmdOpen(), diff --git a/evennia/contrib/grid/slow_exit/tests.py b/evennia/contrib/grid/slow_exit/tests.py index f5feae20eb..db6c42b360 100644 --- a/evennia/contrib/grid/slow_exit/tests.py +++ b/evennia/contrib/grid/slow_exit/tests.py @@ -4,7 +4,7 @@ Slow exit tests. """ from mock import Mock, patch -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.create import create_object from . import slow_exit @@ -16,7 +16,7 @@ def _cancellable_mockdelay(time, callback, *args, **kwargs): return Mock() -class TestSlowExit(EvenniaCommandTest): +class TestSlowExit(BaseEvenniaCommandTest): @patch("evennia.utils.delay", _cancellable_mockdelay) def test_exit(self): exi = create_object( diff --git a/evennia/contrib/rpg/dice/tests.py b/evennia/contrib/rpg/dice/tests.py index d9c9110c7e..b7569ffd8b 100644 --- a/evennia/contrib/rpg/dice/tests.py +++ b/evennia/contrib/rpg/dice/tests.py @@ -3,13 +3,13 @@ Testing of TestDice. """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from mock import patch from . import dice @patch("evennia.contrib.rpg.dice.dice.randint", return_value=5) -class TestDice(EvenniaCommandTest): +class TestDice(BaseEvenniaCommandTest): def test_roll_dice(self, mocked_randint): self.assertEqual(dice.roll_dice(6, 6, modifier=("+", 4)), mocked_randint() * 6 + 4) self.assertEqual(dice.roll_dice(6, 6, conditional=("<", 35)), True) diff --git a/evennia/contrib/rpg/rpsystem/tests.py b/evennia/contrib/rpg/rpsystem/tests.py index e764b99e41..292d9b0469 100644 --- a/evennia/contrib/rpg/rpsystem/tests.py +++ b/evennia/contrib/rpg/rpsystem/tests.py @@ -4,7 +4,7 @@ Tests for RP system """ import time from anything import Anything -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.test_resources import BaseEvenniaTest from evennia import create_object @@ -278,7 +278,7 @@ class TestRPSystem(BaseEvenniaTest): self.assertEqual(result, (Anything, self.speaker, self.speaker.key)) -class TestRPSystemCommands(EvenniaCommandTest): +class TestRPSystemCommands(BaseEvenniaCommandTest): def setUp(self): super().setUp() self.char1.swap_typeclass(rpsystem.ContribRPCharacter) diff --git a/evennia/contrib/tutorials/talking_npc/tests.py b/evennia/contrib/tutorials/talking_npc/tests.py index 95e46a7f26..102816366f 100644 --- a/evennia/contrib/tutorials/talking_npc/tests.py +++ b/evennia/contrib/tutorials/talking_npc/tests.py @@ -2,12 +2,12 @@ Tutorial - talking NPC tests. """ -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.create import create_object from . import talking_npc -class TestTalkingNPC(EvenniaCommandTest): +class TestTalkingNPC(BaseEvenniaCommandTest): def test_talkingnpc(self): npc = create_object(talking_npc.TalkingNPC, key="npctalker", location=self.room1) self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)") diff --git a/evennia/contrib/tutorials/tutorial_world/tests.py b/evennia/contrib/tutorials/tutorial_world/tests.py index 925a92b6c5..07f4391a72 100644 --- a/evennia/contrib/tutorials/tutorial_world/tests.py +++ b/evennia/contrib/tutorials/tutorial_world/tests.py @@ -6,7 +6,7 @@ Test tutorial_world/mob from mock import patch from twisted.trial.unittest import TestCase as TwistedTestCase from twisted.internet.base import DelayedCall -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest from evennia.utils.create import create_object from evennia.utils.test_resources import BaseEvenniaTest, mockdelay, mockdeferLater from . import mob, objects as tutobjects, rooms as tutrooms @@ -30,7 +30,7 @@ class TestTutorialWorldMob(BaseEvenniaTest): DelayedCall.debug = True -class TestTutorialWorldObjects(TwistedTestCase, EvenniaCommandTest): +class TestTutorialWorldObjects(TwistedTestCase, BaseEvenniaCommandTest): def test_tutorialobj(self): obj1 = create_object(tutobjects.TutorialObject, key="tutobj") obj1.reset() @@ -129,7 +129,7 @@ class TestTutorialWorldObjects(TwistedTestCase, EvenniaCommandTest): self.call(tutobjects.CmdGetWeapon(), "", "You find Rusty sword.", obj=rack) -class TestTutorialWorldRooms(EvenniaCommandTest): +class TestTutorialWorldRooms(BaseEvenniaCommandTest): def test_cmdtutorial(self): room = create_object(tutrooms.TutorialRoom, key="tutroom") self.char1.location = room diff --git a/evennia/server/tests/test_server.py b/evennia/server/tests/test_server.py index 580bb21dec..ba5203dc37 100644 --- a/evennia/server/tests/test_server.py +++ b/evennia/server/tests/test_server.py @@ -4,9 +4,8 @@ Test the main server component """ from unittest import TestCase -from mock import MagicMock, patch, DEFAULT, call from django.test import override_settings -from evennia.utils.test_resources import unload_module +from mock import MagicMock, patch, DEFAULT, call @patch("evennia.server.server.LoopingCall", new=MagicMock()) @@ -191,7 +190,7 @@ class TestServer(TestCase): evennia.run_initial_setup() acct.delete() - @override_settings(DEFAULT_HOME="#1") + @override_settings(_TEST_ENVIRONMENT=True) def test_run_init_hooks(self): from evennia.utils import create diff --git a/evennia/utils/test_resources.py b/evennia/utils/test_resources.py index bbf879baf2..c4907d44a5 100644 --- a/evennia/utils/test_resources.py +++ b/evennia/utils/test_resources.py @@ -1,12 +1,34 @@ """ Various helper resources for writing unittests. +Classes for testing Evennia core: + +- `BaseEvenniaTestCase` - no default objects, only enforced default settings +- `BaseEvenniaTest` - all default objects, enforced default settings +- `BaseEvenniaCommandTest` - for testing Commands, enforced default settings + +Classes for testing game folder content: + +- `EvenniaTestCase` - no default objects, using gamedir settings (identical to + standard Python TestCase) +- `EvenniaTest` - all default objects, using gamedir settings +- `EvenniaCommandTest` - for testing game folder commands, using gamedir settings + +Other: + +- `EvenniaTestMixin` - A class mixin for creating the test environment objects, for + making custom tests. +- `EvenniaCommandMixin` - A class mixin that adds support for command testing with the .call() + helper. Used by the command-test classes, but can be used for making a customt test class. + """ import sys +import re +import types from twisted.internet.defer import Deferred from django.conf import settings from django.test import TestCase, override_settings -from mock import Mock, patch +from mock import Mock, patch, MagicMock from evennia.objects.objects import DefaultObject, DefaultCharacter, DefaultRoom, DefaultExit from evennia.accounts.accounts import DefaultAccount from evennia.scripts.scripts import DefaultScript @@ -14,8 +36,14 @@ from evennia.server.serversession import ServerSession from evennia.server.sessionhandler import SESSIONS from evennia.utils import create from evennia.utils.idmapper.models import flush_cache -from evennia.utils.utils import all_from_module +from evennia.utils.utils import all_from_module, to_str +from evennia.utils import ansi from evennia import settings_default +from evennia.commands.default.muxcommand import MuxCommand +from evennia.commands.command import InterruptCommand + + +_RE_STRIP_EVMENU = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE) # set up a 'pristine' setting, unaffected by any changes in mygame @@ -242,14 +270,275 @@ class EvenniaTestMixin: super().tearDown() +@patch("evennia.server.portal.portal.LoopingCall", new=MagicMock()) +class EvenniaCommandTestMixin: + """ + Mixin to add to a test in order to provide the `.call` helper for + testing the execution and returns of a command. + + Tests a Command by running it and comparing what messages it sends with + expected values. This tests without actually spinning up the cmdhandler + for every test, which is more controlled. + + Example: + :: + + from commands.echo import CmdEcho + + class MyCommandTest(EvenniaTest, CommandTestMixin): + + def test_echo(self): + ''' + Test that the echo command really returns + what you pass into it. + ''' + self.call(MyCommand(), "hello world!", + "You hear your echo: 'Hello world!'") + + """ + + # formatting for .call's error message + _ERROR_FORMAT = """ +=========================== Wanted message =================================== +{expected_msg} +=========================== Returned message ================================= +{returned_msg} +============================================================================== +""".rstrip() + + def call( + self, + cmdobj, + input_args, + msg=None, + cmdset=None, + noansi=True, + caller=None, + receiver=None, + cmdstring=None, + obj=None, + inputs=None, + raw_string=None, + ): + """ + Test a command by assigning all the needed properties to a cmdobj and + running the sequence. The resulting `.msg` calls will be mocked and + the text= calls to them compared to a expected output. + + Args: + cmdobj (Command): The command object to use. + input_args (str): This should be the full input the Command should + see, such as 'look here'. This will become `.args` for the Command + instance to parse. + msg (str or dict, optional): This is the expected return value(s) + returned through `caller.msg(text=...)` calls in the command. If a string, the + receiver is controlled with the `receiver` kwarg (defaults to `caller`). + If this is a `dict`, it is a mapping + `{receiver1: "expected1", receiver2: "expected2",...}` and `receiver` is + ignored. The message(s) are compared with the actual messages returned + to the receiver(s) as the Command runs. Each check uses `.startswith`, + so you can choose to only include the first part of the + returned message if that's enough to verify a correct result. EvMenu + decorations (like borders) are stripped and should not be included. This + should also not include color tags unless `noansi=False`. + If the command returns texts in multiple separate `.msg`- + calls to a receiver, separate these with `|` if `noansi=True` + (default) and `||` if `noansi=False`. If no `msg` is given (`None`), + then no automatic comparison will be done. + cmdset (str, optional): If given, make `.cmdset` available on the Command + instance as it runs. While `.cmdset` is normally available on the + Command instance by default, this is usually only used by + commands that explicitly operates/displays cmdsets, like + `examine`. + noansi (str, optional): By default the color tags of the `msg` is + ignored, this makes them significant. If unset, `msg` must contain + the same color tags as the actual return message. + caller (Object or Account, optional): By default `self.char1` is used as the + command-caller (the `.caller` property on the Command). This allows to + execute with another caller, most commonly an Account. + receiver (Object or Account, optional): This is the object to receive the + return messages we want to test. By default this is the same as `caller` + (which in turn defaults to is `self.char1`). Note that if `msg` is + a `dict`, this is ignored since the receiver is already specified there. + cmdstring (str, optional): Normally this is the Command's `key`. + This allows for tweaking the `.cmdname` property of the + Command`. This isb used for commands with multiple aliases, + where the command explicitly checs which alias was used to + determine its functionality. + obj (str, optional): This sets the `.obj` property of the Command - the + object on which the Command 'sits'. By default this is the same as `caller`. + This can be used for testing on-object Command interactions. + inputs (list, optional): A list of strings to pass to functions that pause to + take input from the user (normally using `@interactive` and + `ret = yield(question)` or `evmenu.get_input`). Each element of the + list will be passed into the command as if the user wrote that at the prompt. + raw_string (str, optional): Normally the `.raw_string` property is set as + a combination of your `key/cmdname` and `input_args`. This allows + direct control of what this is, for example for testing edge cases + or malformed inputs. + + Returns: + str or dict: The message sent to `receiver`, or a dict of + `{receiver: "msg", ...}` if multiple are given. This is usually + only used with `msg=None` to do the validation externally. + + Raises: + AssertionError: If the returns of `.msg` calls (tested with `.startswith`) does not + match `expected_input`. + + Notes: + As part of the tests, all methods of the Command will be called in + the proper order: + + - cmdobj.at_pre_cmd() + - cmdobj.parse() + - cmdobj.func() + - cmdobj.at_post_cmd() + + """ + # The `self.char1` is created in the `EvenniaTest` base along with + # other helper objects like self.room and self.obj + caller = caller if caller else self.char1 + cmdobj.caller = caller + cmdobj.cmdname = cmdstring if cmdstring else cmdobj.key + cmdobj.raw_cmdname = cmdobj.cmdname + cmdobj.cmdstring = cmdobj.cmdname # deprecated + cmdobj.args = input_args + cmdobj.cmdset = cmdset + cmdobj.session = SESSIONS.session_from_sessid(1) + cmdobj.account = self.account + cmdobj.raw_string = raw_string if raw_string is not None else cmdobj.key + " " + input_args + cmdobj.obj = obj or (caller if caller else self.char1) + inputs = inputs or [] + + # set up receivers + receiver_mapping = {} + if isinstance(msg, dict): + # a mapping {receiver: msg, ...} + receiver_mapping = {recv: str(msg).strip() if msg else None + for recv, msg in msg.items()} + else: + # a single expected string and thus a single receiver (defaults to caller) + receiver = receiver if receiver else caller + receiver_mapping[receiver] = str(msg).strip() if msg is not None else None + + unmocked_msg_methods = {} + for receiver in receiver_mapping: + # save the old .msg method so we can get it back + # cleanly after the test + unmocked_msg_methods[receiver] = receiver.msg + # replace normal `.msg` with a mock + receiver.msg = Mock() + + # Run the methods of the Command. This mimics what happens in the + # cmdhandler. This will have the mocked .msg be called as part of the + # execution. Mocks remembers what was sent to them so we will be able + # to retrieve what was sent later. + try: + if cmdobj.at_pre_cmd(): + return + cmdobj.parse() + ret = cmdobj.func() + + # handle func's with yield in them (making them generators) + if isinstance(ret, types.GeneratorType): + while True: + try: + inp = inputs.pop() if inputs else None + if inp: + try: + # this mimics a user's reply to a prompt + ret.send(inp) + except TypeError: + next(ret) + ret = ret.send(inp) + else: + # non-input yield, like yield(10). We don't pause + # but fire it immediately. + next(ret) + except StopIteration: + break + + cmdobj.at_post_cmd() + except StopIteration: + pass + except InterruptCommand: + pass + + for inp in inputs: + # if there are any inputs left, we may have a non-generator + # input to handle (get_input/ask_yes_no that uses a separate + # cmdset rather than a yield + caller.execute_cmd(inp) + + # At this point the mocked .msg methods on each receiver will have + # stored all calls made to them (that's a basic function of the Mock + # class). We will not extract them and compare to what we expected to + # go to each receiver. + + returned_msgs = {} + for receiver, expected_msg in receiver_mapping.items(): + # get the stored messages from the Mock with Mock.mock_calls. + stored_msg = [ + args[0] if args and args[0] else kwargs.get("text", to_str(kwargs)) + for name, args, kwargs in receiver.msg.mock_calls + ] + # we can return this now, we are done using the mock + receiver.msg = unmocked_msg_methods[receiver] + + # Get the first element of a tuple if msg received a tuple instead of a string + stored_msg = [str(smsg[0]) + if isinstance(smsg, tuple) else str(smsg) for smsg in stored_msg] + if expected_msg is None: + # no expected_msg; just build the returned_msgs dict + + returned_msg = "\n".join(str(msg) for msg in stored_msg) + returned_msgs[receiver] = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip() + else: + # compare messages to expected + + # set our separator for returned messages based on parsing ansi or not + msg_sep = "|" if noansi else "||" + + # We remove Evmenu decorations since that just makes it harder + # to write the comparison string. We also strip ansi before this + # comparison since otherwise it would mess with the regex. + returned_msg = msg_sep.join( + _RE_STRIP_EVMENU.sub( + "", ansi.parse_ansi(mess, strip_ansi=noansi)) + for mess in stored_msg).strip() + + # this is the actual test + if expected_msg == "" and returned_msg or not returned_msg.startswith(expected_msg): + # failed the test + raise AssertionError( + self._ERROR_FORMAT.format( + expected_msg=expected_msg, returned_msg=returned_msg) + ) + # passed! + returned_msgs[receiver] = returned_msg + + if len(returned_msgs) == 1: + return list(returned_msgs.values())[0] + return returned_msgs + + +# Base testing classes + @override_settings(**DEFAULT_SETTINGS) class BaseEvenniaTestCase(TestCase): """ - Base test (with no default objects) but with - enforced default settings. + Base test (with no default objects) but with enforced default settings. """ +class EvenniaTestCase(TestCase): + """ + For use with gamedir settings; Just like the normal test case, only for naming consistency. + + """ + pass + @override_settings(**DEFAULT_SETTINGS) class BaseEvenniaTest(EvenniaTestMixin, TestCase): @@ -258,7 +547,6 @@ class BaseEvenniaTest(EvenniaTestMixin, TestCase): """ - class EvenniaTest(EvenniaTestMixin, TestCase): """ This test class is intended for inheriting in mygame tests. @@ -273,3 +561,28 @@ class EvenniaTest(EvenniaTestMixin, TestCase): exit_typeclass = settings.BASE_EXIT_TYPECLASS room_typeclass = settings.BASE_ROOM_TYPECLASS script_typeclass = settings.BASE_SCRIPT_TYPECLASS + + +@patch("evennia.commands.account.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.admin.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.batchprocess.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.building.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.comms.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.general.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.help.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.syscommands.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.system.COMMAND_DEFAULT_CLASS", MuxCommand) +@patch("evennia.commands.unloggedin.COMMAND_DEFAULT_CLASS", MuxCommand) +class BaseEvenniaCommandTest(BaseEvenniaTest, EvenniaCommandTestMixin): + """ + Commands only using the default settings. + + """ + + +class EvenniaCommandTest(EvenniaTest, EvenniaCommandTestMixin): + """ + Parent class to inherit from - makes tests use your own + classes and settings in mygame. + + """ diff --git a/evennia/utils/tests/test_eveditor.py b/evennia/utils/tests/test_eveditor.py index 61b88472cd..ec8cacbade 100644 --- a/evennia/utils/tests/test_eveditor.py +++ b/evennia/utils/tests/test_eveditor.py @@ -4,10 +4,10 @@ Test eveditor """ from evennia.utils import eveditor -from evennia.commands.default.tests import EvenniaCommandTest +from evennia.commands.default.tests import BaseEvenniaCommandTest -class TestEvEditor(EvenniaCommandTest): +class TestEvEditor(BaseEvenniaCommandTest): def test_eveditor_view_cmd(self): eveditor.EvEditor(self.char1) self.call(