Source code for evennia.utils.test_resources

"""
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 re
import sys
import types

from django.conf import settings
from django.test import TestCase, override_settings
from mock import MagicMock, Mock, patch
from twisted.internet.defer import Deferred

from evennia import settings_default
from evennia.accounts.accounts import DefaultAccount
from evennia.commands.command import InterruptCommand
from evennia.commands.default.muxcommand import MuxCommand
from evennia.objects.objects import (
    DefaultCharacter,
    DefaultExit,
    DefaultObject,
    DefaultRoom,
)
from evennia.scripts.scripts import DefaultScript
from evennia.server.serversession import ServerSession
from evennia.server.sessionhandler import SESSIONS
from evennia.utils import ansi, create
from evennia.utils.idmapper.models import flush_cache
from evennia.utils.utils import all_from_module, to_str

_RE_STRIP_EVMENU = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE)


# set up a 'pristine' setting, unaffected by any changes in mygame
DEFAULT_SETTING_RESETS = dict(
    CONNECTION_SCREEN_MODULE="evennia.game_template.server.conf.connection_screens",
    AT_SERVER_STARTSTOP_MODULE="evennia.game_template.server.conf.at_server_startstop",
    AT_SERVICES_PLUGINS_MODULES=["evennia.game_template.server.conf.server_services_plugins"],
    PORTAL_SERVICES_PLUGIN_MODULES=["evennia.game_template.server.conf.portal_services_plugins"],
    MSSP_META_MODULE="evennia.game_template.server.conf.mssp",
    WEB_PLUGINS_MODULE="server.conf.web_plugins",
    LOCK_FUNC_MODULES=("evennia.locks.lockfuncs", "evennia.game_template.server.conf.lockfuncs"),
    INPUT_FUNC_MODULES=[
        "evennia.server.inputfuncs",
        "evennia.game_template.server.conf.inputfuncs",
    ],
    PROTOTYPE_MODULES=["evennia.game_template.world.prototypes"],
    CMDSET_UNLOGGEDIN="evennia.game_template.commands.default_cmdsets.UnloggedinCmdSet",
    CMDSET_SESSION="evennia.game_template.commands.default_cmdsets.SessionCmdSet",
    CMDSET_CHARACTER="evennia.game_template.commands.default_cmdsets.CharacterCmdSet",
    CMDSET_ACCOUNT="evennia.game_template.commands.default_cmdsets.AccountCmdSet",
    CMDSET_PATHS=["evennia.game_template.commands", "evennia", "evennia.contrib"],
    TYPECLASS_PATHS=[
        "evennia",
        "evennia.contrib",
        "evennia.contrib.game_systems",
        "evennia.contrib.base_systems",
        "evennia.contrib.full_systems",
        "evennia.contrib.tutorials",
        "evennia.contrib.utils",
    ],
    BASE_ACCOUNT_TYPECLASS="evennia.accounts.accounts.DefaultAccount",
    BASE_OBJECT_TYPECLASS="evennia.objects.objects.DefaultObject",
    BASE_CHARACTER_TYPECLASS="evennia.objects.objects.DefaultCharacter",
    BASE_ROOM_TYPECLASS="evennia.objects.objects.DefaultRoom",
    BASE_EXIT_TYPECLASS="evennia.objects.objects.DefaultExit",
    BASE_CHANNEL_TYPECLASS="evennia.comms.comms.DefaultChannel",
    BASE_SCRIPT_TYPECLASS="evennia.scripts.scripts.DefaultScript",
    BASE_BATCHPROCESS_PATHS=[
        "evennia.game_template.world",
        "evennia.contrib",
        "evennia.contrib.tutorials",
    ],
    FILE_HELP_ENTRY_MODULES=["evennia.game_template.world.help_entries"],
    FUNCPARSER_OUTGOING_MESSAGES_MODULES=[
        "evennia.utils.funcparser",
        "evennia.game_template.server.conf.inlinefuncs",
    ],
    FUNCPARSER_PROTOTYPE_PARSING_MODULES=[
        "evennia.prototypes.protfuncs",
        "evennia.game_template.server.conf.prototypefuncs",
    ],
    BASE_GUEST_TYPECLASS="evennia.accounts.accounts.DefaultGuest",
    # a special setting boolean _TEST_ENVIRONMENT is set by the test runner
    # while the test suite is running.
)

DEFAULT_SETTINGS = {**all_from_module(settings_default), **DEFAULT_SETTING_RESETS}
DEFAULT_SETTINGS.pop("DATABASES")  # we want different dbs tested in CI


# mocking of evennia.utils.utils.delay
[docs]def mockdelay(timedelay, callback, *args, **kwargs): callback(*args, **kwargs) return Deferred()
# mocking of twisted's deferLater
[docs]def mockdeferLater(reactor, timedelay, callback, *args, **kwargs): callback(*args, **kwargs) return Deferred()
[docs]def unload_module(module): """ Reset import so one can mock global constants. Args: module (module, object or str): The module will be removed so it will have to be imported again. If given an object, the module in which that object sits will be unloaded. A string should directly give the module pathname to unload. Example: ```python # (in a test method) unload_module(foo) with mock.patch("foo.GLOBALTHING", "mockval"): import foo ... # test code using foo.GLOBALTHING, now set to 'mockval' ``` This allows for mocking constants global to the module, since otherwise those would not be mocked (since a module is only loaded once). """ if isinstance(module, str): modulename = module elif hasattr(module, "__module__"): modulename = module.__module__ else: modulename = module.__name__ if modulename in sys.modules: del sys.modules[modulename]
def _mock_deferlater(reactor, timedelay, callback, *args, **kwargs): callback(*args, **kwargs) return Deferred()
[docs]class EvenniaTestMixin: """ Evennia test environment mixin """ account_typeclass = DefaultAccount object_typeclass = DefaultObject character_typeclass = DefaultCharacter exit_typeclass = DefaultExit room_typeclass = DefaultRoom script_typeclass = DefaultScript
[docs] def create_accounts(self): self.account = create.create_account( "TestAccount", email="test@test.com", password="testpassword", typeclass=self.account_typeclass, ) self.account2 = create.create_account( "TestAccount2", email="test@test.com", password="testpassword", typeclass=self.account_typeclass, ) self.account.permissions.add("Developer")
[docs] def teardown_accounts(self): if hasattr(self, "account"): self.account.delete() if hasattr(self, "account2"): self.account2.delete()
# Set up fake prototype module for allowing tests to use named prototypes.
[docs] @override_settings( PROTOTYPE_MODULES=["evennia.utils.tests.data.prototypes_example"], DEFAULT_HOME="#1" ) def create_rooms(self): self.room1 = create.create_object(self.room_typeclass, key="Room", nohome=True) self.room1.db.desc = "room_desc" self.room2 = create.create_object(self.room_typeclass, key="Room2") self.exit = create.create_object( self.exit_typeclass, key="out", location=self.room1, destination=self.room2 )
[docs] def create_objs(self): self.obj1 = create.create_object( self.object_typeclass, key="Obj", location=self.room1, home=self.room1 ) self.obj2 = create.create_object( self.object_typeclass, key="Obj2", location=self.room1, home=self.room1 )
[docs] def create_chars(self): self.char1 = create.create_object( self.character_typeclass, key="Char", location=self.room1, home=self.room1 ) self.char1.permissions.add("Developer") self.char2 = create.create_object( self.character_typeclass, key="Char2", location=self.room1, home=self.room1 ) self.char1.account = self.account self.account.db._last_puppet = self.char1 self.char2.account = self.account2 self.account2.db._last_puppet = self.char2
[docs] def create_script(self): self.script = create.create_script(self.script_typeclass, key="Script")
[docs] def setup_session(self): dummysession = ServerSession() dummysession.init_session("telnet", ("localhost", "testmode"), SESSIONS) dummysession.sessid = 1 SESSIONS.portal_connect( dummysession.get_sync_data() ) # note that this creates a new Session! session = SESSIONS.session_from_sessid(1) # the real session SESSIONS.login(session, self.account, testmode=True) self.session = session
[docs] def teardown_session(self): if hasattr(self, "sessions"): del SESSIONS[self.session.sessid]
[docs] @patch("evennia.scripts.taskhandler.deferLater", _mock_deferlater) def setUp(self): """ Sets up testing environment """ self.backups = ( SESSIONS.data_out, SESSIONS.disconnect, settings.DEFAULT_HOME, settings.PROTOTYPE_MODULES, ) SESSIONS.data_out = Mock() SESSIONS.disconnect = Mock() self.create_accounts() self.create_rooms() self.create_objs() self.create_chars() self.create_script() self.setup_session()
[docs] def tearDown(self): flush_cache() try: SESSIONS.data_out = self.backups[0] SESSIONS.disconnect = self.backups[1] settings.DEFAULT_HOME = self.backups[2] settings.PROTOTYPE_MODULES = self.backups[3] except AttributeError as err: raise AttributeError( f"{err}: Teardown error. If you overrode the `setUp()` method " "in your test, make sure you also added `super().setUp()`!" ) del SESSIONS[self.session.sessid] self.teardown_accounts() super().tearDown()
[docs]@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()
[docs] 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 answered each prompt in that order. 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
[docs]@override_settings(**DEFAULT_SETTINGS) class BaseEvenniaTestCase(TestCase): """ Base test (with no default objects) but with enforced default settings. """
[docs]class EvenniaTestCase(TestCase): """ For use with gamedir settings; Just like the normal test case, only for naming consistency. """ pass
[docs]@override_settings(**DEFAULT_SETTINGS) class BaseEvenniaTest(EvenniaTestMixin, TestCase): """ This class parent has all default objects and uses only default settings. """
[docs]class EvenniaTest(EvenniaTestMixin, TestCase): """ This test class is intended for inheriting in mygame tests. It helps ensure your tests are run with your own objects and settings from your game folder. """ account_typeclass = settings.BASE_ACCOUNT_TYPECLASS object_typeclass = settings.BASE_OBJECT_TYPECLASS character_typeclass = settings.BASE_CHARACTER_TYPECLASS exit_typeclass = settings.BASE_EXIT_TYPECLASS room_typeclass = settings.BASE_ROOM_TYPECLASS script_typeclass = settings.BASE_SCRIPT_TYPECLASS
[docs]@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. """
[docs]class EvenniaCommandTest(EvenniaTest, EvenniaCommandTestMixin): """ Parent class to inherit from - makes tests use your own classes and settings in mygame. """