Add unit tests for syscmds, refactor cmdparser

This commit is contained in:
Griatch 2019-02-01 23:23:41 +01:00
parent 55eb026e95
commit 11d39a57b5
4 changed files with 170 additions and 119 deletions

View file

@ -15,6 +15,107 @@ _MULTIMATCH_REGEX = re.compile(settings.SEARCH_MULTIMATCH_REGEX, re.I + re.U)
_CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES
def create_match(cmdname, string, cmdobj, raw_cmdname):
"""
Builds a command match by splitting the incoming string and
evaluating the quality of the match.
Args:
cmdname (str): Name of command to check for.
string (str): The string to match against.
cmdobj (str): The full Command instance.
raw_cmdname (str, optional): If CMD_IGNORE_PREFIX is set and the cmdname starts with
one of the prefixes to ignore, this contains the raw, unstripped cmdname,
otherwise it is None.
Returns:
match (tuple): This is on the form (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname),
where `cmdname` is the command's name and `args` is the rest of the incoming
string, without said command name. `cmdobj` is
the Command instance, the cmdlen is the same as len(cmdname) and mratio
is a measure of how big a part of the full input string the cmdname
takes up - an exact match would be 1.0. Finally, the `raw_cmdname` is
the cmdname unmodified by eventual prefix-stripping.
"""
cmdlen, strlen = len(unicode(cmdname)), len(unicode(string))
mratio = 1 - (strlen - cmdlen) / (1.0 * strlen)
args = string[cmdlen:]
return (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname)
def build_matches(raw_string, cmdset, include_prefixes=False):
"""
Build match tuples by matching raw_string against available commands.
Args:
raw_string (str): Input string that can look in any way; the only assumption is
that the sought command's name/alias must be *first* in the string.
cmdset (CmdSet): The current cmdset to pick Commands from.
include_prefixes (bool): If set, include prefixes like @, ! etc (specified in settings)
in the match, otherwise strip them before matching.
Returns:
matches (list) A list of match tuples created by `cmdparser.create_match`.
"""
l_raw_string = raw_string.lower()
matches = []
try:
if include_prefixes:
# use the cmdname as-is
for cmd in cmdset:
matches.extend([create_match(cmdname, raw_string, cmd, cmdname)
for cmdname in [cmd.key] + cmd.aliases
if cmdname and l_raw_string.startswith(cmdname.lower()) and
(not cmd.arg_regex or
cmd.arg_regex.match(l_raw_string[len(cmdname):]))])
else:
# strip prefixes set in settings
for cmd in cmdset:
for raw_cmdname in [cmd.key] + cmd.aliases:
cmdname = raw_cmdname.lstrip(_CMD_IGNORE_PREFIXES) if len(raw_cmdname) > 1 else raw_cmdname
if cmdname and l_raw_string.startswith(cmdname.lower()) and \
(not cmd.arg_regex or cmd.arg_regex.match(l_raw_string[len(cmdname):])):
matches.append(create_match(cmdname, raw_string, cmd, raw_cmdname))
except Exception:
log_trace("cmdhandler error. raw_input:%s" % raw_string)
return matches
def try_num_prefixes(raw_string):
"""
Test if user tried to separate multi-matches with a number separator
(default 1-name, 2-name etc). This is usually called last, if no other
match was found.
Args:
raw_string (str): The user input to parse.
Returns:
mindex, new_raw_string (tuple): If a multimatch-separator was detected,
this is stripped out as an integer to separate between the matches. The
new_raw_string is the result of stripping out that identifier. If no
such form was found, returns (None, None).
Example:
In the default configuration, entering 2-ball (e.g. in a room will more
than one 'ball' object), will lead to a multimatch and this function
will parse `"2-ball"` and return `(2, "ball")`.
"""
# no matches found
num_ref_match = _MULTIMATCH_REGEX.match(raw_string)
if num_ref_match:
# the user might be trying to identify the command
# with a #num-command style syntax. We expect the regex to
# contain the groups "number" and "name".
mindex, new_raw_string = num_ref_match.group("number"), num_ref_match.group("name")
return mindex, new_raw_string
else:
return None, None
def cmdparser(raw_string, cmdset, caller, match_index=None):
"""
This function is called by the cmdhandler once it has
@ -30,6 +131,10 @@ def cmdparser(raw_string, cmdset, caller, match_index=None):
the first run resulted in a multimatch, and the index is given
to select between the results for the second run.
Returns:
matches (list): This is a list of match-tuples as returned by `create_match`.
If no matches were found, this is an empty list.
Notes:
The cmdparser understand the following command combinations (where
[] marks optional parts.
@ -48,74 +153,11 @@ def cmdparser(raw_string, cmdset, caller, match_index=None):
"""
def create_match(cmdname, string, cmdobj, raw_cmdname):
"""
Builds a command match by splitting the incoming string and
evaluating the quality of the match.
Args:
cmdname (str): Name of command to check for.
string (str): The string to match against.
cmdobj (str): The full Command instance.
raw_cmdname (str, optional): If CMD_IGNORE_PREFIX is set and the cmdname starts with
one of the prefixes to ignore, this contains the raw, unstripped cmdname,
otherwise it is None.
Returns:
match (tuple): This is on the form (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname), where
`cmdname` is the command's name and `args` the rest of the incoming string,
without said command name. `cmdobj` is the Command instance, the cmdlen is
the same as len(cmdname) and mratio is a measure of how big a part of the
full input string the cmdname takes up - an exact match would be 1.0. Finally,
the `raw_cmdname` is the cmdname unmodified by eventual prefix-stripping.
"""
cmdlen, strlen = len(unicode(cmdname)), len(unicode(string))
mratio = 1 - (strlen - cmdlen) / (1.0 * strlen)
args = string[cmdlen:]
return (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname)
def build_matches(raw_string, include_prefixes=False):
l_raw_string = raw_string.lower()
matches = []
try:
if include_prefixes:
# use the cmdname as-is
for cmd in cmdset:
matches.extend([create_match(cmdname, raw_string, cmd, cmdname)
for cmdname in [cmd.key] + cmd.aliases
if cmdname and l_raw_string.startswith(cmdname.lower()) and
(not cmd.arg_regex or
cmd.arg_regex.match(l_raw_string[len(cmdname):]))])
else:
# strip prefixes set in settings
for cmd in cmdset:
for raw_cmdname in [cmd.key] + cmd.aliases:
cmdname = raw_cmdname.lstrip(_CMD_IGNORE_PREFIXES) if len(raw_cmdname) > 1 else raw_cmdname
if cmdname and l_raw_string.startswith(cmdname.lower()) and \
(not cmd.arg_regex or cmd.arg_regex.match(l_raw_string[len(cmdname):])):
matches.append(create_match(cmdname, raw_string, cmd, raw_cmdname))
except Exception:
log_trace("cmdhandler error. raw_input:%s" % raw_string)
return matches
def try_num_prefixes(raw_string):
if not matches:
# no matches found
num_ref_match = _MULTIMATCH_REGEX.match(raw_string)
if num_ref_match:
# the user might be trying to identify the command
# with a #num-command style syntax. We expect the regex to
# contain the groups "number" and "name".
mindex, new_raw_string = num_ref_match.group("number"), num_ref_match.group("name")
return mindex, new_raw_string
return None, None
if not raw_string:
return []
# find mathces, first using the full name
matches = build_matches(raw_string, include_prefixes=True)
matches = build_matches(raw_string, cmdset, include_prefixes=True)
if not matches:
# try to match a number 1-cmdname, 2-cmdname etc
mindex, new_raw_string = try_num_prefixes(raw_string)
@ -124,7 +166,7 @@ def cmdparser(raw_string, cmdset, caller, match_index=None):
if _CMD_IGNORE_PREFIXES:
# still no match. Try to strip prefixes
raw_string = raw_string.lstrip(_CMD_IGNORE_PREFIXES) if len(raw_string) > 1 else raw_string
matches = build_matches(raw_string, include_prefixes=False)
matches = build_matches(raw_string, cmdset, include_prefixes=False)
# only select command matches we are actually allowed to call.
matches = [match for match in matches if match[2].access(caller, 'cmd')]

View file

@ -20,6 +20,7 @@ the line is just added to the editor buffer).
from evennia.comms.models import ChannelDB
from evennia.utils import create
from evennia.utils.utils import at_search_result
# The command keys the engine is calling
# (the actual names all start with __)
@ -76,57 +77,30 @@ class SystemMultimatch(COMMAND_DEFAULT_CLASS):
The cmdhandler adds a special attribute 'matches' to this
system command.
matches = [(candidate, cmd) , (candidate, cmd), ...],
matches = [(cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname) , (cmdname, ...), ...]
Here, `cmdname` is the command's name and `args` the rest of the incoming string,
without said command name. `cmdobj` is the Command instance, the cmdlen is
the same as len(cmdname) and mratio is a measure of how big a part of the
full input string the cmdname takes up - an exact match would be 1.0. Finally,
the `raw_cmdname` is the cmdname unmodified by eventual prefix-stripping.
where candidate is an instance of evennia.commands.cmdparser.CommandCandidate
and cmd is an an instantiated Command object matching the candidate.
"""
key = CMD_MULTIMATCH
locks = "cmd:all()"
def format_multimatches(self, caller, matches):
"""
Format multiple command matches to a useful error.
This is copied directly from the default method in
evennia.commands.cmdhandler.
"""
string = "There were multiple matches:"
for num, match in enumerate(matches):
# each match is a tuple (candidate, cmd)
candidate, cmd = match
is_channel = hasattr(cmd, "is_channel") and cmd.is_channel
if is_channel:
is_channel = " (channel)"
else:
is_channel = ""
is_exit = hasattr(cmd, "is_exit") and cmd.is_exit
if is_exit and cmd.destination:
is_exit = " (exit to %s)" % cmd.destination
else:
is_exit = ""
id1 = ""
id2 = ""
if not (is_channel or is_exit) and (hasattr(cmd, 'obj') and cmd.obj != caller):
# the command is defined on some other object
id1 = "%s-" % cmd.obj.name
id2 = " (%s-%s)" % (num + 1, candidate.cmdname)
else:
id1 = "%s-" % (num + 1)
id2 = ""
string += "\n %s%s%s%s%s" % (id1, candidate.cmdname, id2, is_channel, is_exit)
return string
def func(self):
"""
argument to cmd is a comma-separated string of
all the clashing matches.
Handle multiple-matches by using the at_search_result default handler.
"""
string = self.format_multimatches(self.caller, self.matches)
self.msg(string)
# this was set by the cmdparser and is a tuple
# (cmdname, args, cmdobj, cmdlen, mratio, raw_cmdname). See
# evennia.commands.cmdparse.create_match for more details.
matches = self.matches
# at_search_result will itself msg the multimatch options to the caller.
at_search_result(
[match[2] for match in matches], self.caller, query=matches[0][0])
# Command called when the command given at the command line

View file

@ -21,9 +21,12 @@ from mock import Mock, mock
from evennia.commands.default.cmdset_character import CharacterCmdSet
from evennia.utils.test_resources import EvenniaTest
from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms, unloggedin
from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms, unloggedin, syscommands
from evennia.commands.cmdparser import build_matches
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
from evennia.server.sessionhandler import SESSIONS
from evennia import search_object
@ -283,30 +286,30 @@ class TestAccount(CommandTest):
def test_char_create(self):
self.call(account.CmdCharCreate(), "Test1=Test char",
"Created new character Test1. Use @ic Test1 to enter the game", caller=self.account)
def test_char_delete(self):
# Chardelete requires user input; this test is mainly to confirm
# Chardelete requires user input; this test is mainly to confirm
# whether permissions are being checked
# Add char to account playable characters
self.account.db._playable_characters.append(self.char1)
# Try deleting as Developer
self.call(account.CmdCharDelete(), "Char", "This will permanently destroy 'Char'. This cannot be undone. Continue yes/[no]?", caller=self.account)
# Downgrade permissions on account
self.account.permissions.add('Player')
self.account.permissions.remove('Developer')
# Set lock on character object to prevent deletion
self.char1.locks.add('delete:none()')
# Try deleting as Player
self.call(account.CmdCharDelete(), "Char", "You do not have permission to delete this character.", caller=self.account)
# Set lock on character object to allow self-delete
self.char1.locks.add('delete:pid(%i)' % self.account.id)
# Try deleting as Player again
self.call(account.CmdCharDelete(), "Char", "This will permanently destroy 'Char'. This cannot be undone. Continue yes/[no]?", caller=self.account)
@ -657,3 +660,33 @@ class TestUnconnectedCommand(CommandTest):
datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(),
SESSIONS.account_count(), utils.get_evennia_version())
self.call(unloggedin.CmdUnconnectedInfo(), "", expected)
# Test syscommands
class TestSystemCommands(CommandTest):
def test_simple_defaults(self):
self.call(syscommands.SystemNoInput(), "")
self.call(syscommands.SystemNoMatch(), "Huh?")
def test_multimatch(self):
# set up fake matches and store on command instance
cmdset = CmdSet()
cmdset.add(general.CmdLook())
cmdset.add(general.CmdLook())
matches = cmdparser.build_matches("look", cmdset)
multimatch = syscommands.SystemMultimatch()
multimatch.matches = matches
self.call(multimatch, "look", "")
@mock.patch("evennia.commands.default.syscommands.ChannelDB")
def test_channelcommand(self, mock_channeldb):
channel = mock.MagicMock()
channel.msg = mock.MagicMock()
mock_channeldb.objects.get_channel = mock.MagicMock(return_value=channel)
self.call(syscommands.SystemSendToChannel(), "public:Hello")
channel.msg.assert_called()

View file

@ -1857,7 +1857,9 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs):
Returns:
processed_result (Object or None): This is always a single result
or `None`. If `None`, any error reporting/handling should
already have happened.
already have happened. The returned object is of the type we are
checking multimatches for (e.g. Objects or Commands)
"""
error = ""