Refactor all test classes into evennia.utils.test_resources. Update docs.

This commit is contained in:
Griatch 2022-01-21 00:17:24 +01:00
parent 7912351e01
commit bbf45af2dd
28 changed files with 528 additions and 588 deletions

View file

@ -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?")